"""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*\)") 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}