Files
SaraView/Backend/Portals.py
T

253 lines
7.5 KiB
Python

from __future__ import annotations
import re
from Core import ResolvePath
from Explorer import ClassifyFile, ListDirectory
from Locks import IsPathAccessible
from Preview import ReadTextFile
PortalFileTypes = {"markdown", "text"}
MarkerPattern = re.compile(
r"\{(?:(?P<CrossPath>[^\s{}']+)\s+)?\*\*\*(?P<CrossName>[^\s*{}]+)\}"
r"|\{(?P<LinePath>[^\s{}']+)\s+'(?P<LineNumber>\d+)'\}"
r"|\{\*\*(?P<LineName>[^\s*{}]+)\}"
r"|\{\*(?P<BlockName>[^\s*{}]+)\}"
)
def _LineBounds(Content: str, Position: int) -> tuple[int, int]:
LineStart = Content.rfind("\n", 0, Position) + 1
LineEnd = Content.find("\n", Position)
if LineEnd == -1:
LineEnd = len(Content)
return LineStart, LineEnd
def _FindLocalDefinitions(Content: str) -> dict[str, str]:
Matches = list(MarkerPattern.finditer(Content))
LineDefinitions: dict[str, str] = {}
BlockOccurrences: dict[str, list[re.Match]] = {}
for Match in Matches:
if Match.group("LineName"):
Name = Match.group("LineName")
if Name not in LineDefinitions:
LineStart, LineEnd = _LineBounds(Content, Match.start())
Line = Content[LineStart:LineEnd]
Captured = (
Line[: Match.start() - LineStart] + Line[Match.end() - LineStart :]
).strip()
LineDefinitions[Name] = _ResolveLocalPortals(Captured)
elif Match.group("BlockName"):
Name = Match.group("BlockName")
BlockOccurrences.setdefault(Name, []).append(Match)
BlockDefinitions: dict[str, str] = {}
for Name, Occurrences in BlockOccurrences.items():
Open = Occurrences[0]
if len(Occurrences) >= 2:
Close = Occurrences[1]
Captured = Content[Open.end() : Close.start()].strip("\n")
else:
Captured = Content[Open.end() :].lstrip("\n")
BlockDefinitions[Name] = _ResolveLocalPortals(Captured)
return {**LineDefinitions, **BlockDefinitions}
def _ResolveLocalPortals(Content: str) -> str:
Matches = list(MarkerPattern.finditer(Content))
Definitions = _FindLocalDefinitions(Content)
LineSeenCount: dict[str, int] = {}
BlockSeenCount: dict[str, int] = {}
Output: list[str] = []
Cursor = 0
for Match in Matches:
Replacement = None
if Match.group("LineName"):
Name = Match.group("LineName")
Occurrence = LineSeenCount.get(Name, 0)
LineSeenCount[Name] = Occurrence + 1
Replacement = "" if Occurrence == 0 else Definitions.get(Name, "")
elif Match.group("BlockName"):
Name = Match.group("BlockName")
Occurrence = BlockSeenCount.get(Name, 0)
BlockSeenCount[Name] = Occurrence + 1
Replacement = "" if Occurrence in (0, 1) else Definitions.get(Name, "")
if Replacement is not None:
Output.append(Content[Cursor : Match.start()])
Output.append(Replacement)
Cursor = Match.end()
Output.append(Content[Cursor:])
return "".join(Output)
def _NormalizeRelativePath(BaseDirectory: str, RawPath: str) -> str:
Combined = f"{BaseDirectory}/{RawPath}" if BaseDirectory else RawPath
Parts: list[str] = []
for Part in Combined.split("/"):
if Part in ("", "."):
continue
if Part == "..":
if Parts:
Parts.pop()
continue
Parts.append(Part)
return "/".join(Parts)
def _ReadAccessiblePortalSource(
RootName: str, RelativePath: str, Username: str, UserGroups: list[str]
) -> str | None:
if not IsPathAccessible(RootName, RelativePath, Username, UserGroups):
return None
try:
FilePath = ResolvePath(RootName, RelativePath)
except ValueError:
return None
if not FilePath.is_file() or ClassifyFile(FilePath) not in PortalFileTypes:
return None
try:
return ReadTextFile(FilePath)
except (OSError, UnicodeDecodeError):
return None
def _FindNamedPortalInDirectory(
RootName: str, DirectoryRelativePath: str, Name: str, Username: str, UserGroups: list[str]
) -> str | None:
if not IsPathAccessible(RootName, DirectoryRelativePath, Username, UserGroups):
return None
try:
Entries = ListDirectory(RootName, DirectoryRelativePath, False, False)
except (ValueError, NotADirectoryError, FileNotFoundError):
return None
for Entry in Entries:
if Entry["IsDirectory"] or Entry["FileType"] not in PortalFileTypes:
continue
EntryPath = (
f"{DirectoryRelativePath}/{Entry['Name']}" if DirectoryRelativePath else Entry["Name"]
)
Content = _ReadAccessiblePortalSource(RootName, EntryPath, Username, UserGroups)
if Content is None:
continue
Definitions = _FindLocalDefinitions(Content)
if Name in Definitions:
return Definitions[Name]
return None
def _ResolveCrossFileReference(
RootName: str,
RefererDirectory: str,
PathText: str | None,
Name: str,
Username: str,
UserGroups: list[str],
) -> str | None:
if not PathText:
return _FindNamedPortalInDirectory(RootName, RefererDirectory, Name, Username, UserGroups)
TargetRelativePath = _NormalizeRelativePath(RefererDirectory, PathText)
try:
TargetPath = ResolvePath(RootName, TargetRelativePath)
except ValueError:
return None
if TargetPath.is_dir():
return _FindNamedPortalInDirectory(
RootName, TargetRelativePath, Name, Username, UserGroups
)
Content = _ReadAccessiblePortalSource(RootName, TargetRelativePath, Username, UserGroups)
if Content is None:
return None
return _FindLocalDefinitions(Content).get(Name)
def _ResolveLineNumberReference(
RootName: str,
RefererDirectory: str,
PathText: str,
LineNumber: int,
Username: str,
UserGroups: list[str],
) -> str | None:
TargetRelativePath = _NormalizeRelativePath(RefererDirectory, PathText)
Content = _ReadAccessiblePortalSource(RootName, TargetRelativePath, Username, UserGroups)
if Content is None:
return None
Lines = Content.splitlines()
Index = LineNumber - 1
if 0 <= Index < len(Lines):
return Lines[Index]
return None
def ResolvePortals(
RootName: str, RelativePath: str, Content: str, Username: str, UserGroups: list[str]
) -> str:
LocallyResolved = _ResolveLocalPortals(Content)
RefererDirectory = RelativePath.rsplit("/", 1)[0] if "/" in RelativePath else ""
Matches = list(MarkerPattern.finditer(LocallyResolved))
Output: list[str] = []
Cursor = 0
for Match in Matches:
Replacement = None
if Match.group("CrossName"):
Replacement = _ResolveCrossFileReference(
RootName,
RefererDirectory,
Match.group("CrossPath"),
Match.group("CrossName"),
Username,
UserGroups,
)
elif Match.group("LineNumber"):
Replacement = _ResolveLineNumberReference(
RootName,
RefererDirectory,
Match.group("LinePath"),
int(Match.group("LineNumber")),
Username,
UserGroups,
)
if Replacement is not None:
Output.append(LocallyResolved[Cursor : Match.start()])
Output.append(Replacement)
Cursor = Match.end()
Output.append(LocallyResolved[Cursor:])
return "".join(Output)