Files
SaraView/Backend/Graph.py
T
2026-10-09 09:16:38 +02:00

161 lines
6.4 KiB
Python

"""The graph view's data (like Obsidian's graph): every directory and file
under one directory, joined to its parent directory, plus a link from a
file to every file or directory it references.
References are read from Markdown and text files:
- Markdown links and images: [Text](Other.md), ![](Images/Cat.png)
- Wiki links: [[Other]], [[Other|Shown]], ![[Cat.png]] (matched by name,
like Obsidian, preferring the nearest one)
- Portals: {Path ***Name}, {Path head 3}, {Path '12'}
Listing follows the same rules as /api/tree (hidden files, .sv_ notes,
locks), so the graph never shows what the file list wouldn't. The desktop
client builds the same graph for local files (Client/Graph.py)."""
from __future__ import annotations
import re
from urllib.parse import unquote
from Core import NormalizeRelativePath, ResolvePath
from Explorer import ListDirectory
from Locks import IsPathAccessible
from Preview import ReadTextFile
from VarFunctions import MarkerPattern
# Past this, the graph is cut short (and says so) rather than taking ages
# to build and draw.
MaxNodes = 2000
# Bigger files aren't read for references.
MaxReadBytes = 1_000_000
ReferenceFileTypes = {"markdown", "text"}
FencedCodePattern = re.compile(r"^ {0,3}(```|~~~).*?^ {0,3}\1", re.MULTILINE | re.DOTALL)
MarkdownLinkPattern = re.compile(r"!?\[[^\]\n]*\]\(\s*<?([^)\s>]+)>?(?:\s+\"[^\"]*\")?\s*\)")
WikiLinkPattern = re.compile(r"!?\[\[([^\]|#\n]+)(?:#[^\]|\n]*)?(?:\|[^\]\n]*)?\]\]")
ExternalPattern = re.compile(r"^([a-z][a-z0-9+.-]*:|//|#)", re.IGNORECASE)
def FindReferences(Content: str) -> list[tuple[str, str]]:
"""("Path", relative path) and ("Name", wiki name) references in a file.
Code blocks are skipped, since links in them are examples."""
Content = FencedCodePattern.sub("", Content)
Found: list[tuple[str, str]] = []
for Match in MarkdownLinkPattern.finditer(Content):
Target = Match.group(1)
if ExternalPattern.match(Target):
continue
Target = unquote(Target.split("#", 1)[0].split("?", 1)[0])
if Target:
Found.append(("Path", Target))
for Match in WikiLinkPattern.finditer(Content):
Found.append(("Name", Match.group(1).strip()))
for Match in MarkerPattern.finditer(Content):
Target = Match.group("CrossPath") or Match.group("PipePath") or Match.group("LinePath")
if Target:
Found.append(("Path", Target))
return Found
def ResolveReferences(
References: list[tuple[str, str]], FileId: str, Ids: set[str], ByName: dict[str, list[str]]
) -> set[str]:
"""Node ids (paths relative to the root) that a file's references point to."""
Directory = FileId.rsplit("/", 1)[0] if "/" in FileId else ""
Targets: set[str] = set()
for Kind, Target in References:
if Kind == "Path":
Base = "" if Target.startswith("/") else Directory
Resolved = NormalizeRelativePath(Base, Target)
if Resolved in Ids:
Targets.add(Resolved)
continue
# Wiki link: a path ("Sub/Note"), else a name anywhere, nearest first.
for Candidate in (Target, f"{Target}.md"):
for Base in (Directory, ""):
Resolved = NormalizeRelativePath(Base, Candidate)
if Resolved in Ids:
Targets.add(Resolved)
break
else:
continue
break
else:
Matches = ByName.get(Target.rsplit("/", 1)[-1].lower(), [])
if Matches:
Targets.add(min(Matches, key=lambda Id: (_Distance(Directory, Id), Id)))
Targets.discard(FileId)
return Targets
def _Distance(Directory: str, Id: str) -> int:
"""How many directories apart a file in Directory and Id are."""
Here = Directory.split("/") if Directory else []
There = Id.split("/")[:-1]
Shared = 0
for A, B in zip(Here, There):
if A != B:
break
Shared += 1
return len(Here) + len(There) - 2 * Shared
def IndexByName(Nodes: list[dict]) -> dict[str, list[str]]:
"""Files by lowercased name and by name without its extension, for wiki links."""
ByName: dict[str, list[str]] = {}
for Node in Nodes:
if Node["IsDirectory"]:
continue
Name = Node["Name"].lower()
ByName.setdefault(Name, []).append(Node["Id"])
if "." in Name:
ByName.setdefault(Name.rsplit(".", 1)[0], []).append(Node["Id"])
return ByName
def BuildGraph(
Root: str, Path: str, ShowHidden: bool, ShowSvFiles: bool, Username: str | None, UserGroups: list[str]
) -> dict:
Path = NormalizeRelativePath("", Path)
Nodes: list[dict] = [{"Id": Path, "Name": Path.rsplit("/", 1)[-1] if Path else Root,
"IsDirectory": True, "FileType": None}]
Links: list[dict] = []
Truncated = False
Pending = [Path]
while Pending and not Truncated:
Directory = Pending.pop(0)
try:
Entries = ListDirectory(Root, Directory, ShowHidden, ShowSvFiles)
except (ValueError, NotADirectoryError, FileNotFoundError, PermissionError):
continue
for Entry in Entries:
Id = f"{Directory}/{Entry['Name']}" if Directory else Entry["Name"]
if not IsPathAccessible(Root, Id, Username, UserGroups):
continue
if len(Nodes) >= MaxNodes:
Truncated = True
break
Nodes.append({"Id": Id, "Name": Entry["Name"], "IsDirectory": Entry["IsDirectory"],
"FileType": Entry.get("FileType")})
Links.append({"Source": Directory, "Target": Id, "Kind": "Child"})
if Entry["IsDirectory"]:
Pending.append(Id)
Ids = {Node["Id"] for Node in Nodes}
ByName = IndexByName(Nodes)
for Node in Nodes:
if Node["IsDirectory"] or Node["FileType"] not in ReferenceFileTypes:
continue
try:
FilePath = ResolvePath(Root, Node["Id"])
if FilePath.stat().st_size > MaxReadBytes:
continue
Content = ReadTextFile(FilePath)
except (ValueError, OSError, UnicodeDecodeError):
continue
for Target in sorted(ResolveReferences(FindReferences(Content), Node["Id"], Ids, ByName)):
Links.append({"Source": Node["Id"], "Target": Target, "Kind": "Reference"})
return {"Root": Root, "Path": Path, "Nodes": Nodes, "Links": Links, "Truncated": Truncated}