mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-13 18:28:14 +02:00
161 lines
6.4 KiB
Python
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), 
|
|
- 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}
|