diff --git a/Backend/Graph.py b/Backend/Graph.py new file mode 100644 index 0000000..89e2e4d --- /dev/null +++ b/Backend/Graph.py @@ -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} diff --git a/Backend/Main.py b/Backend/Main.py index 5c6c57d..cca553a 100644 --- a/Backend/Main.py +++ b/Backend/Main.py @@ -60,6 +60,7 @@ from Locks import ( RequirePathAccessible, SetLock, ) +from Graph import BuildGraph from Preview import GetMimeType, ReadTextFile, StreamBinaryFile, TextFileTypes from Settings import ( DefaultSettings, @@ -406,6 +407,27 @@ def GetTreeEndpoint( ] +@Application.get("/api/graph") +def GetGraphEndpoint( + Root: str, Path: str = "", CurrentUser: dict = Depends(GetCurrentUserOrGuest) +) -> dict: + """Directories, files and the links between them under Path (see Graph.py).""" + RequirePathAccessible(Root, Path, CurrentUser) + UserSettings = DefaultSettings if CurrentUser["Username"] is None else LoadSettings(CurrentUser["Username"]) + + try: + return BuildGraph( + Root, + Path, + UserSettings.get("ShowHiddenFolders", False), + UserSettings.get("ShowSvFiles", False), + CurrentUser["Username"], + CurrentUser.get("Groups", []), + ) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + + @Application.post("/api/note") def PostNoteEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict: RequirePathAccessible(Root, Path, CurrentUser) diff --git a/Client/Browser.py b/Client/Browser.py index 661142d..6cbb606 100644 --- a/Client/Browser.py +++ b/Client/Browser.py @@ -13,6 +13,7 @@ import flet as ft from App import App from Core import ChipButtonStyle, FileIcons, HumanSize, OpenExternally, Palette from Sources import BaseName, Entry, JoinPath, MoveDestination, ParentPath, SourceError +from Graph import GraphView from Viewer import FileView DoubleClickWindow = 0.4 # seconds @@ -41,6 +42,7 @@ class Browser(ft.Column): self.BackStack: list[tuple[Optional[str], str]] = [] self.ForwardStack: list[tuple[Optional[str], str]] = [] self.OpenView: Optional[FileView] = None + self.GraphView: Optional[GraphView] = None self.ShortcutsPaused = False # a text field has focus self.LastClickName: Optional[str] = None self.LastClickTime = 0.0 @@ -71,6 +73,8 @@ class Browser(ft.Column): self.NewFileButton, self.NewDirectoryButton, self.UploadButton, + ft.IconButton(icon=ft.Icons.HUB_OUTLINED, icon_color=Palette.Mauve, tooltip="Graph (Ctrl+G)", + on_click=lambda e: self.ShowGraph()), ft.IconButton(icon=ft.Icons.REFRESH, icon_color=Palette.Mauve, tooltip="Refresh (Ctrl+R)", on_click=lambda e: self.Refresh()), self.ConnectionButton, @@ -168,6 +172,7 @@ class Browser(ft.Column): self.Root, self.Path = Root, RelativePath self.SelectedName = None self.OpenView = None + self.GraphView = None self.ShortcutsPaused = False self._LoadEntries() self._ShowList() @@ -194,6 +199,9 @@ class Browser(ft.Column): def Refresh(self) -> None: if self.OpenView is not None: return # reloading would throw away edits; close the file first + if self.GraphView is not None: + self.GraphView.Reload() + return self._LoadEntries() self._ShowList() @@ -358,6 +366,33 @@ class Browser(ft.Column): self.EntryRows[RowName].update() self.SelectedName = Name + def ShowGraph(self) -> None: + """The graph of the root being browsed, in place of the file list.""" + if self.OpenView is not None: + self.OpenView.RequestClose(Then=self.ShowGraph) + return + Root = self.Root + if Root is None: + Roots = self._Roots() + if not Roots: + return + Root = Roots[0] + self.GraphView = GraphView(self.TheApp, Root, self.Path if Root == self.Root else "", + OnOpenNode=lambda Node: self._OpenGraphNode(Root, Node), OnClose=self.CloseGraph) + self.FileArea.content = self.GraphView + self.FileArea.update() + + def CloseGraph(self) -> None: + self.GraphView = None + self._ShowList() + + def _OpenGraphNode(self, Root: str, Node: dict) -> None: + if Node["IsDirectory"]: + self.NavigateTo(Root, Node["Id"]) + else: + self.GraphView = None + self.OpenFile(Root, Node["Id"]) + def OpenFile(self, Root: str, RelativePath: str) -> None: """Opens a file from anywhere (the sidebar tree): goes to its directory first, so Back and Esc land in the list it's in.""" @@ -626,6 +661,12 @@ class Browser(ft.Column): if not View.HandleEscape(): View.RequestClose() return + if Event.key == "Escape" and self.GraphView is not None: + self.CloseGraph() + return + if Event.key == "G" and Event.ctrl and not self.ShortcutsPaused: + self.CloseGraph() if self.GraphView is not None else self.ShowGraph() + return if self.ShortcutsPaused: return diff --git a/Client/Graph.py b/Client/Graph.py new file mode 100644 index 0000000..3b73f73 --- /dev/null +++ b/Client/Graph.py @@ -0,0 +1,416 @@ +"""The graph view, like Obsidian's (and the web view's GraphPane.jsx): every +directory and file under a directory as a dot (directories Mauve, files +White), joined to its parent directory and to every file that references it. + +On a server the graph comes from /api/graph; locally BuildLocalGraph makes +the same one with the same reference rules as Backend/Graph.py. The layout is +the web view's force simulation (same constants), in numpy, drawn as it +settles. Scroll or pinch to zoom, drag to pan, hover a dot to light up its +links, click it to open it.""" + +import math +import re +import threading +from typing import Callable, Optional +from urllib.parse import unquote + +import flet as ft +import flet.canvas as cv +import numpy as np + +import Core +from App import App +from Core import Palette +from Sources import JoinPath, NormalizeRelativePath, SourceError + +MaxNodes = 2000 +MaxReadBytes = 1_000_000 +ReferenceFileTypes = {"markdown", "text"} + +# Same patterns as Backend/Graph.py. +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) +# The path part of a portal: {Path ***Name}, {Path head 3}, {Path '12'} +# (Backend/VarFunctions.py's MarkerPattern, just the parts with a path). +PortalPathPattern = re.compile(r"\{([^\s{}'*]+)\s+(?:\*\*\*[^\s*{}]+|(?:head|tail)\s+\d+|'\d+')[^{}]*\}") + +# Layout, same as GraphPane.jsx. +ChildLength = 50 +ReferenceLength = 80 +Repulsion = 900 +CellSize = 160 +TicksPerDraw = 12 + + +# --- Building the graph (local files) ------------------------------------- + +def FindReferences(Content: str) -> list[tuple[str, str]]: + 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 PortalPathPattern.finditer(Content): + Found.append(("Path", Match.group(1))) + return Found + + +def _Distance(Directory: str, Id: str) -> int: + 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 ResolveReferences(References: list[tuple[str, str]], FileId: str, Ids: set[str], + ByName: dict[str, list[str]]) -> set[str]: + Directory = FileId.rsplit("/", 1)[0] if "/" in FileId else "" + Targets: set[str] = set() + for Kind, Target in References: + if Kind == "Path": + Resolved = NormalizeRelativePath("" if Target.startswith("/") else Directory, Target) + if Resolved in Ids: + Targets.add(Resolved) + continue + Found = next((NormalizeRelativePath(Base, Candidate) + for Candidate in (Target, f"{Target}.md") for Base in (Directory, "") + if NormalizeRelativePath(Base, Candidate) in Ids), None) + if Found is None: + Matches = ByName.get(Target.rsplit("/", 1)[-1].lower(), []) + if Matches: + Found = min(Matches, key=lambda Id: (_Distance(Directory, Id), Id)) + if Found is not None: + Targets.add(Found) + Targets.discard(FileId) + return Targets + + +def BuildLocalGraph(Source, Root: str, Path: str) -> dict: + """Backend/Graph.py's BuildGraph, through a Source's own listing (so the + same files are hidden as in the file list).""" + Nodes = [{"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: + Items = Source.List(Root, Directory) + except SourceError: + continue + for Item in Items: + if len(Nodes) >= MaxNodes: + Truncated = True + break + Id = JoinPath(Directory, Item.Name) + Nodes.append({"Id": Id, "Name": Item.Name, "IsDirectory": Item.IsDirectory, "FileType": Item.FileType}) + Links.append({"Source": Directory, "Target": Id, "Kind": "Child"}) + if Item.IsDirectory: + Pending.append(Id) + + Ids = {Node["Id"] for Node in Nodes} + ByName: dict[str, list[str]] = {} + for Node in Nodes: + if not Node["IsDirectory"]: + Name = Node["Name"].lower() + ByName.setdefault(Name, []).append(Node["Id"]) + if "." in Name: + ByName.setdefault(Name.rsplit(".", 1)[0], []).append(Node["Id"]) + for Node in Nodes: + if Node["IsDirectory"] or Node["FileType"] not in ReferenceFileTypes: + continue + try: + if Source.LocalFile(Root, Node["Id"]).stat().st_size > MaxReadBytes: + continue + Content = Source.ReadText(Root, Node["Id"]) + except (SourceError, OSError): + 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} + + +# --- Layout ---------------------------------------------------------------- + +class Layout: + def __init__(self, Graph: dict): + self.Nodes = Graph["Nodes"] + Index = {Node["Id"]: Number for Number, Node in enumerate(self.Nodes)} + Links = [Link for Link in Graph["Links"] if Link["Source"] in Index and Link["Target"] in Index] + self.Kinds = [Link["Kind"] for Link in Links] + self.Sources = np.array([Index[Link["Source"]] for Link in Links], dtype=int) + self.Targets = np.array([Index[Link["Target"]] for Link in Links], dtype=int) + IsChild = np.array([Kind == "Child" for Kind in self.Kinds], dtype=bool) + self.Rest = np.where(IsChild, ChildLength, ReferenceLength).astype(float) + self.Strength = np.where(IsChild, 0.08, 0.03) + Count = len(self.Nodes) + self.Degree = np.bincount(np.concatenate([self.Sources, self.Targets]), minlength=Count) if Links else np.zeros(Count) + self.Neighbours: list[set[int]] = [set() for _ in range(Count)] + for A, B in zip(self.Sources, self.Targets): + self.Neighbours[A].add(int(B)) + self.Neighbours[B].add(int(A)) + self.Position = self._Initial(Links, Index) + self.Velocity = np.zeros_like(self.Position) + self.Alpha = 1.0 + + def _Initial(self, Links: list[dict], Index: dict[str, int]) -> np.ndarray: + """Each dot near its parent, spread around it (seeded, like the web view).""" + Random = np.random.default_rng(12345) + Position = np.zeros((len(self.Nodes), 2)) + Placed = np.zeros(len(self.Nodes), dtype=bool) + Placed[0] = True + Children: dict[int, list[int]] = {} + for Link in Links: + if Link["Kind"] == "Child": + Children.setdefault(Index[Link["Source"]], []).append(Index[Link["Target"]]) + Queue = [0] + while Queue: + Parent = Queue.pop(0) + Kids = Children.get(Parent, []) + Start = Random.random() * math.tau + for Number, Child in enumerate(Kids): + Angle = Start + Number / len(Kids) * math.tau + Distance = ChildLength * (1 + Random.random() * 0.5) + Position[Child] = Position[Parent] + (math.cos(Angle) * Distance, math.sin(Angle) * Distance) + Placed[Child] = True + Queue.append(Child) + Loose = ~Placed + Position[Loose] = (Random.random((Loose.sum(), 2)) - 0.5) * 200 + return Position + + @property + def Settled(self) -> bool: + return self.Alpha <= 0.005 + + def Tick(self) -> None: + Position, Velocity, Alpha = self.Position, self.Velocity, self.Alpha + Count = len(Position) + for Start in range(0, Count, 256): # in chunks, so memory stays small for big graphs + Delta = Position[Start:Start + 256, None, :] - Position[None, :, :] + DistanceSquared = np.maximum((Delta ** 2).sum(-1), 1.0) + Force = Repulsion * Alpha / DistanceSquared + Force[DistanceSquared > CellSize * CellSize] = 0 + Velocity[Start:Start + 256] += (Delta * Force[..., None]).sum(1) + if len(self.Sources): + Delta = Position[self.Targets] - Position[self.Sources] + Distance = np.maximum(np.sqrt((Delta ** 2).sum(-1)), 1e-6) + Pull = ((Distance - self.Rest) / Distance * self.Strength * Alpha)[:, None] * Delta + np.add.at(Velocity, self.Sources, Pull) + np.add.at(Velocity, self.Targets, -Pull) + Velocity -= Position * 0.002 * Alpha + Velocity *= 0.6 + Position += Velocity + self.Alpha *= 0.985 + + +# --- View -------------------------------------------------------------------- + +class GraphView(ft.Container): + def __init__(self, TheApp: App, Root: str, DirectoryPath: str, + OnOpenNode: Callable[[dict], None], OnClose: Callable[[], None]): + self.TheApp = TheApp + self.Root = Root + self.DirectoryPath = DirectoryPath + self.OnOpenNode = OnOpenNode + self.Layout: Optional[Layout] = None + self.Size = (800.0, 600.0) + self.Screen = np.zeros((0, 2)) # each dot's position on the canvas + self.Radius = np.zeros(0) + self.Hovered: Optional[int] = None + self.Closed = False + self.Generation = 0 # bumps when the scope changes, so an old layout thread stops + + self.ScopePicker = ft.Dropdown( + value="Root", + options=[ft.DropdownOption(key="Root", text="Whole root"), + ft.DropdownOption(key="Directory", text=f"This directory ({DirectoryPath})" if DirectoryPath else "This directory", + disabled=not DirectoryPath)], + dense=True, width=260, text_size=13, + on_select=lambda e: self.Reload(), + ) + self.CountText = ft.Text("", size=12, color=Palette.Overlay0) + Legend = ft.Row([ + ft.Text("●", color=Palette.Mauve), ft.Text("Directory", size=12, color=Palette.Overlay0), + ft.Text("●", color=Palette.White), ft.Text("File", size=12, color=Palette.Overlay0), + ft.Text("─", color=Palette.Blue), ft.Text("Reference", size=12, color=Palette.Overlay0), + ], spacing=4, tight=True) + self.BaseCanvas = cv.Canvas(shapes=[], expand=True, on_resize=self._OnResize, resize_interval=100) + self.LitCanvas = cv.Canvas(shapes=[], expand=True) + self.Status = ft.Text("Building the graph…", color=Palette.Overlay0) + Surface = ft.GestureDetector( + content=ft.Stack([self.BaseCanvas, self.LitCanvas], expand=True), + on_tap_up=self._OnTap, + on_hover=self._OnHover, + hover_interval=50, + mouse_cursor=ft.MouseCursor.BASIC, + expand=True, + ) + self.Viewer = ft.InteractiveViewer( + content=Surface, min_scale=0.2, max_scale=8, boundary_margin=ft.Margin.all(4000), + expand=True, + ) + super().__init__( + content=ft.Column([ + ft.Row([ + ft.IconButton(icon=ft.Icons.ARROW_BACK, tooltip="Back to the file list (Esc)", on_click=lambda e: OnClose()), + ft.Text("Graph", weight=ft.FontWeight.BOLD), + self.ScopePicker, Legend, ft.Container(expand=True), self.CountText, + ], spacing=12), + ft.Divider(height=1, color=Palette.Surface1), + ft.Stack([self.Viewer, ft.Container(self.Status, alignment=ft.Alignment.CENTER)], expand=True), + ], spacing=8, expand=True), + expand=True, bgcolor=Palette.Base, padding=12, + ) + + def did_mount(self) -> None: + self.Reload() + + def will_unmount(self) -> None: + self.Closed = True + + # --- Data and layout ---------------------------------------------------- + + def Reload(self) -> None: + self.Generation += 1 + Generation = self.Generation + Path = self.DirectoryPath if self.ScopePicker.value == "Directory" else "" + self.Status.value = "Building the graph…" + self.Status.visible = True + self.BaseCanvas.shapes = [] + self.LitCanvas.shapes = [] + self.Layout = None + self.update() + self.page.run_thread(self._Build, Generation, Path) + + def _Build(self, Generation: int, Path: str) -> None: + try: + Graph = self.TheApp.Source.Graph(self.Root, Path) + except SourceError as Error: + if Generation == self.Generation and not self.Closed: + self.Status.value = f"Couldn't build the graph: {Error}" + self.Status.update() + return + if Generation != self.Generation or self.Closed: + return + Files = sum(1 for Node in Graph["Nodes"] if not Node["IsDirectory"]) + self.CountText.value = (f"{len(Graph['Nodes']) - Files} directories, {Files} files" + + (" (cut short: too many to show)" if Graph.get("Truncated") else "")) + self.CountText.update() + self.Layout = Layout(Graph) + self.Status.visible = False + self.Status.update() + # Settle in steps, drawing as it goes, like the web view's animation. + while not self.Layout.Settled: + for _ in range(TicksPerDraw): + self.Layout.Tick() + if Generation != self.Generation or self.Closed: + return + self._Draw() + self._Draw() + + # --- Drawing ------------------------------------------------------------ + + def _Fit(self) -> None: + """Maps the layout onto the canvas, so the whole graph fits at zoom 1 + (the viewer's own zoom and pan go from there).""" + Position = self.Layout.Position + Low, High = Position.min(0), Position.max(0) + Width, Height = self.Size + Margin = 40 + Scale = min((Width - 2 * Margin) / max(High[0] - Low[0], 1), (Height - 2 * Margin) / max(High[1] - Low[1], 1), 1.5) + Middle = (Low + High) / 2 + self.Screen = (Position - Middle) * Scale + (Width / 2, Height / 2) + Zoom = math.sqrt(Scale) + IsDirectory = np.array([Node["IsDirectory"] for Node in self.Layout.Nodes]) + Degree = np.sqrt(self.Layout.Degree) + self.Radius = np.maximum(2, np.where(IsDirectory, 5 + Degree * 1.6, 3 + Degree * 0.8) * Zoom) + + def _Label(self, Number: int, Colour: str) -> cv.Text: + X, Y = self.Screen[Number] + return cv.Text(X, Y + self.Radius[Number] + 2, self.Layout.Nodes[Number]["Name"], + style=ft.TextStyle(size=10, color=Colour, font_family=Core.ActiveFontFamily), + alignment=ft.Alignment.TOP_CENTER) + + def _Dot(self, Number: int) -> cv.Circle: + X, Y = self.Screen[Number] + Colour = Palette.Mauve if self.Layout.Nodes[Number]["IsDirectory"] else Palette.White + return cv.Circle(X, Y, self.Radius[Number], paint=ft.Paint(color=Colour)) + + def _Line(self, LinkNumber: int, Colour: str, Width: float = 1) -> cv.Line: + A, B = self.Screen[self.Layout.Sources[LinkNumber]], self.Screen[self.Layout.Targets[LinkNumber]] + return cv.Line(A[0], A[1], B[0], B[1], paint=ft.Paint(color=Colour, stroke_width=Width, style=ft.PaintingStyle.STROKE)) + + def _Draw(self) -> None: + if self.Layout is None or self.Closed: + return + self._Fit() + Shapes: list = [] + for Number, Kind in enumerate(self.Layout.Kinds): + Shapes.append(self._Line(Number, Palette.Surface1 if Kind == "Child" else Palette.Blue)) + Nodes = self.Layout.Nodes + ShowFileLabels = len(Nodes) <= 300 + for Number, Node in enumerate(Nodes): + Shapes.append(self._Dot(Number)) + if Node["IsDirectory"] or ShowFileLabels: + Shapes.append(self._Label(Number, Palette.Overlay0)) + self.BaseCanvas.shapes = Shapes + self.BaseCanvas.update() + self._DrawHighlight() + + def _DrawHighlight(self) -> None: + """The hovered dot and its links, over a veil that dims the rest.""" + Number = self.Hovered + if Number is None or self.Layout is None: + self.LitCanvas.shapes = [] + else: + Width, Height = self.Size + Shapes: list = [cv.Rect(-4000, -4000, Width + 8000, Height + 8000, + paint=ft.Paint(color=ft.Colors.with_opacity(0.75, Palette.Base)))] + Lit = {Number} | self.Layout.Neighbours[Number] + for LinkNumber in range(len(self.Layout.Kinds)): + if Number in (self.Layout.Sources[LinkNumber], self.Layout.Targets[LinkNumber]): + Shapes.append(self._Line(LinkNumber, Palette.Mauve, 1.5)) + for Other in Lit: + Shapes.append(self._Dot(Other)) + Shapes.append(self._Label(Other, Palette.Text if Other == Number else Palette.Overlay0)) + X, Y = self.Screen[Number] + Shapes.append(cv.Circle(X, Y, self.Radius[Number] + 3, + paint=ft.Paint(color=Palette.Mauve, stroke_width=2, style=ft.PaintingStyle.STROKE))) + self.LitCanvas.shapes = Shapes + self.LitCanvas.update() + + # --- Input -------------------------------------------------------------- + + def _NodeAt(self, X: float, Y: float) -> Optional[int]: + if self.Layout is None or not len(self.Screen): + return None + Distance = np.hypot(self.Screen[:, 0] - X, self.Screen[:, 1] - Y) + Number = int(Distance.argmin()) + return Number if Distance[Number] <= max(6, self.Radius[Number] + 4) else None + + def _OnResize(self, Event) -> None: + self.Size = (Event.width, Event.height) + self._Draw() + + def _OnHover(self, Event) -> None: + Number = self._NodeAt(Event.local_position.x, Event.local_position.y) + if Number != self.Hovered: + self.Hovered = Number + self._DrawHighlight() + + def _OnTap(self, Event) -> None: + Number = self._NodeAt(Event.local_position.x, Event.local_position.y) + if Number is not None: + self.OnOpenNode(self.Layout.Nodes[Number]) diff --git a/Client/Sources.py b/Client/Sources.py index e6319b1..ea3a912 100644 --- a/Client/Sources.py +++ b/Client/Sources.py @@ -113,6 +113,7 @@ class Source: def Upload(self, Root: str, DirectoryPath: str, LocalFile: Path, OnProgress: Optional[Callable[[float], None]] = None) -> str: ... def LocalFile(self, Root: str, RelativePath: str) -> Path: ... def Book(self, Root: str, RelativePath: str) -> dict: ... + def Graph(self, Root: str, RelativePath: str) -> dict: ... @property def CanWrite(self) -> bool: @@ -268,6 +269,12 @@ class LocalSource(Source): def Book(self, Root: str, RelativePath: str) -> dict: return {} # SaraTag tags are only read through a server for now + def Graph(self, Root: str, RelativePath: str) -> dict: + """The graph view's data, built here the way the server builds it.""" + from Graph import BuildLocalGraph # Graph.py imports this module + + return BuildLocalGraph(self, Root, RelativePath) + class ServerSource(Source): """A SaraView server, through the same /api endpoints the web frontend @@ -453,6 +460,10 @@ class ServerSource(Source): except SourceError: return {} # tags are extra; a file without them still opens + def Graph(self, Root: str, RelativePath: str) -> dict: + # A big root takes the server a while to read through. + return self._Json("GET", "/graph", params=self._At(Root, RelativePath), timeout=120) + def MakeSource(Config: AppConfig, Token: str = "") -> Source: return ServerSource(Config, Token) if Config.Mode == "Server" else LocalSource(Config) diff --git a/Client/requirements.txt b/Client/requirements.txt index a09d538..c7d014b 100644 --- a/Client/requirements.txt +++ b/Client/requirements.txt @@ -3,3 +3,4 @@ flet-desktop>=1.0,<2.0 # the desktop client; otherwise Flet fetches it on first tomlkit httpx send2trash +numpy diff --git a/Frontend/src/Api.js b/Frontend/src/Api.js index 7e7f577..a99dbf7 100644 --- a/Frontend/src/Api.js +++ b/Frontend/src/Api.js @@ -141,6 +141,11 @@ export function FetchTree(RootName, RelativePath) { return FetchJson(`/tree?${Params}`); } +export function FetchGraph(RootName, RelativePath) { + const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); + return FetchJson(`/graph?${Params}`); +} + export function FetchFile(RootName, RelativePath) { const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); return FetchText(`/file?${Params}`); diff --git a/Frontend/src/App.jsx b/Frontend/src/App.jsx index a4eabb1..c1f2a70 100644 --- a/Frontend/src/App.jsx +++ b/Frontend/src/App.jsx @@ -6,6 +6,7 @@ import PreviewPane from "./Components/PreviewPane.jsx"; import DirectoryPane from "./Components/DirectoryPane.jsx"; import TagPanel from "./Components/TagPanel.jsx"; import SettingsPanel from "./Components/SettingsPanel.jsx"; +import GraphPane from "./Components/GraphPane.jsx"; import LoginScreen from "./Components/LoginScreen.jsx"; import ErrorToast from "./Components/ErrorToast.jsx"; import { @@ -70,6 +71,7 @@ export default function App() { const [SidebarKey, SetSidebarKey] = useState(0); const [Settings, SetSettings] = useState(null); const [IsSettingsOpen, SetIsSettingsOpen] = useState(false); + const [IsGraphOpen, SetIsGraphOpen] = useState(false); const [ErrorMessage, SetErrorMessage] = useState(null); useEffect(() => { @@ -254,6 +256,15 @@ export default function App() { } } + function HandleOpenGraphNode(Node) { + SetIsGraphOpen(false); + if (Node.IsDirectory) { + HandleBreadcrumbNavigate(Node.Id); + } else { + HandleSelectFile(Node.Id, Node.FileType); + } + } + function HandleEntrySelect(EntryPath, IsDirectory, FileType, Note) { if (IsDirectory) { OpenDirectoryNote(EntryPath, Note); @@ -328,6 +339,8 @@ export default function App() { OnEntryRenamed={HandleEntryRenamed} OnEntryDeleted={HandleEntryDeleted} OnOpenSettings={() => SetIsSettingsOpen(true)} + OnToggleGraph={() => SetIsGraphOpen(!IsGraphOpen)} + IsGraphOpen={IsGraphOpen} OnRequestLogin={HandleRequestLogin} OnEntryCreated={RefreshSidebar} UseHyphenBullets={Settings ? Settings.UseHyphenBullets : true} @@ -365,7 +378,14 @@ export default function App() { )}