mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-14 18:38:11 +02:00
Add graph view
This commit is contained in:
1 parent
f851ecd820
commit
57a07874ee
12 files changed
+1152
-2
No files matched your search
@@ -0,0 +1,160 @@
|
||||
"""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}
|
||||
Reference in new issue
Block a user