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), ![](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} 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*\)") +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() { )}
- {ViewIsDirectory ? ( + {IsGraphOpen ? ( + SetIsGraphOpen(false)} + /> + ) : ViewIsDirectory ? ( )} - {!ViewIsDirectory && !ViewNoteFilePath && ( + {!IsGraphOpen && !ViewIsDirectory && !ViewNoteFilePath && ( )}
diff --git a/Frontend/src/Components/GraphPane.jsx b/Frontend/src/Components/GraphPane.jsx new file mode 100644 index 0000000..c849175 --- /dev/null +++ b/Frontend/src/Components/GraphPane.jsx @@ -0,0 +1,401 @@ +import React, { useEffect, useRef, useState } from "react"; + +import { FetchGraph } from "../Api.js"; + +// The graph view, like Obsidian's: every directory and file under a +// directory as a dot (directories Purple, files White), joined to its parent +// directory, and to every file that references it (Backend/Graph.py). +// The layout is a small force simulation: dots push each other apart and +// links pull them together until it settles. Drag the background to pan, +// scroll to zoom, drag a dot to move it, click one to open it. + +const ChildLength = 50; +const ReferenceLength = 80; +const Repulsion = 900; +const CellSize = 160; // dots further apart than this don't push each other +const LabelZoom = 0.7; // below this, only hovered labels are drawn + +function ReadRole(Name) { + return getComputedStyle(document.documentElement).getPropertyValue(`--${Name}`).trim(); +} + +// Starts each dot near its parent, spread around it, so the simulation +// begins close to a tree and settles quickly. Seeded, so the same directory +// always lays out the same way. +function InitialLayout(Nodes, Links) { + let Seed = 12345; + const Random = () => { + Seed = (Seed * 16807) % 2147483647; + return Seed / 2147483647; + }; + const ById = new Map(Nodes.map((Node) => [Node.Id, Node])); + const ChildrenOf = new Map(); + for (const Link of Links) { + if (Link.Kind !== "Child") continue; + if (!ChildrenOf.has(Link.Source)) ChildrenOf.set(Link.Source, []); + ChildrenOf.get(Link.Source).push(Link.Target); + } + const Root = Nodes[0]; + Root.x = 0; + Root.y = 0; + const Queue = [Root]; + while (Queue.length) { + const Parent = Queue.shift(); + const Children = ChildrenOf.get(Parent.Id) || []; + const Start = Random() * Math.PI * 2; + Children.forEach((ChildId, Index) => { + const Child = ById.get(ChildId); + const Angle = Start + (Index / Children.length) * Math.PI * 2; + const Distance = ChildLength * (1 + Random() * 0.5); + Child.x = Parent.x + Math.cos(Angle) * Distance; + Child.y = Parent.y + Math.sin(Angle) * Distance; + Queue.push(Child); + }); + } + for (const Node of Nodes) { + Node.vx = 0; + Node.vy = 0; + if (Node.x === undefined) { + Node.x = (Random() - 0.5) * 200; + Node.y = (Random() - 0.5) * 200; + } + } +} + +function Tick(Nodes, Links, Alpha) { + // Repulsion between dots in the same or neighbouring grid cells. + const Grid = new Map(); + for (const Node of Nodes) { + const Key = `${Math.floor(Node.x / CellSize)},${Math.floor(Node.y / CellSize)}`; + if (!Grid.has(Key)) Grid.set(Key, []); + Grid.get(Key).push(Node); + } + for (const Node of Nodes) { + const CellX = Math.floor(Node.x / CellSize); + const CellY = Math.floor(Node.y / CellSize); + for (let DX = -1; DX <= 1; DX++) { + for (let DY = -1; DY <= 1; DY++) { + const Others = Grid.get(`${CellX + DX},${CellY + DY}`); + if (!Others) continue; + for (const Other of Others) { + if (Other === Node) continue; + let X = Node.x - Other.x; + let Y = Node.y - Other.y; + let DistanceSquared = X * X + Y * Y; + if (DistanceSquared > CellSize * CellSize) continue; + if (DistanceSquared < 1) { + X = Math.random() - 0.5; + Y = Math.random() - 0.5; + DistanceSquared = 1; + } + const Force = (Repulsion * Alpha) / DistanceSquared; + Node.vx += X * Force; + Node.vy += Y * Force; + } + } + } + } + // Links pull towards their rest length. + for (const Link of Links) { + const A = Link.SourceNode; + const B = Link.TargetNode; + const X = B.x - A.x; + const Y = B.y - A.y; + const Distance = Math.sqrt(X * X + Y * Y) || 1; + const Rest = Link.Kind === "Child" ? ChildLength : ReferenceLength; + const Strength = Link.Kind === "Child" ? 0.08 : 0.03; + const Pull = ((Distance - Rest) / Distance) * Strength * Alpha; + A.vx += X * Pull; + A.vy += Y * Pull; + B.vx -= X * Pull; + B.vy -= Y * Pull; + } + // A gentle pull to the middle keeps separate clusters from drifting off. + for (const Node of Nodes) { + Node.vx -= Node.x * 0.002 * Alpha; + Node.vy -= Node.y * 0.002 * Alpha; + if (Node.Pinned) { + Node.vx = 0; + Node.vy = 0; + continue; + } + Node.vx *= 0.6; + Node.vy *= 0.6; + Node.x += Node.vx; + Node.y += Node.vy; + } +} + +export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose }) { + const [Scope, SetScope] = useState("Root"); + const [Graph, SetGraph] = useState(null); + const [Error, SetError] = useState(null); + const CanvasRef = useRef(null); + const StateRef = useRef(null); + + const ScopePath = Scope === "Root" ? "" : DirectoryPath; + + useEffect(() => { + SetGraph(null); + SetError(null); + FetchGraph(RootName, ScopePath) + .then(SetGraph) + .catch((Failure) => SetError(Failure.message)); + }, [RootName, ScopePath]); + + useEffect(() => { + if (!Graph) return undefined; + const Canvas = CanvasRef.current; + const Context = Canvas.getContext("2d"); + const Nodes = Graph.Nodes.map((Node) => ({ ...Node, Degree: 0 })); + const ById = new Map(Nodes.map((Node) => [Node.Id, Node])); + const Links = Graph.Links.filter((Link) => ById.has(Link.Source) && ById.has(Link.Target)).map((Link) => ({ + ...Link, + SourceNode: ById.get(Link.Source), + TargetNode: ById.get(Link.Target), + })); + const Neighbours = new Map(Nodes.map((Node) => [Node, new Set()])); + for (const Link of Links) { + Link.SourceNode.Degree += 1; + Link.TargetNode.Degree += 1; + Neighbours.get(Link.SourceNode).add(Link.TargetNode); + Neighbours.get(Link.TargetNode).add(Link.SourceNode); + } + for (const Node of Nodes) { + Node.Radius = Node.IsDirectory ? 5 + Math.sqrt(Node.Degree) * 1.6 : 3 + Math.sqrt(Node.Degree) * 0.8; + } + InitialLayout(Nodes, Links); + + const Colours = { + Background: ReadRole("Background"), + Directory: ReadRole("Purple"), + File: ReadRole("White"), + Child: ReadRole("Border"), + Reference: ReadRole("Blue"), + Text: ReadRole("Text"), + Muted: ReadRole("TextMuted"), + }; + const FontFamily = ReadRole("FontFamily"); + + const State = { + Zoom: 1, + PanX: 0, + PanY: 0, + Alpha: 1, + Hovered: null, + Dragging: null, + Panning: null, + Moved: false, + Frame: 0, + Width: 0, + Height: 0, + }; + StateRef.current = State; + + function ToScreen(Node) { + return [Node.x * State.Zoom + State.PanX, Node.y * State.Zoom + State.PanY]; + } + + function ToWorld(X, Y) { + return [(X - State.PanX) / State.Zoom, (Y - State.PanY) / State.Zoom]; + } + + function Resize() { + const Rect = Canvas.getBoundingClientRect(); + const Ratio = window.devicePixelRatio || 1; + const First = State.Width === 0; + State.Width = Rect.width; + State.Height = Rect.height; + Canvas.width = Math.round(Rect.width * Ratio); + Canvas.height = Math.round(Rect.height * Ratio); + Context.setTransform(Ratio, 0, 0, Ratio, 0, 0); + if (First) { + State.PanX = Rect.width / 2; + State.PanY = Rect.height / 2; + } + } + + function Draw() { + const Focus = State.Dragging || State.Hovered; + const Lit = Focus ? Neighbours.get(Focus) : null; + Context.fillStyle = Colours.Background; + Context.fillRect(0, 0, State.Width, State.Height); + + for (const Link of Links) { + const Touching = Focus && (Link.SourceNode === Focus || Link.TargetNode === Focus); + Context.globalAlpha = Focus && !Touching ? 0.15 : Link.Kind === "Child" ? 0.8 : 0.9; + Context.strokeStyle = Touching ? Colours.Directory : Link.Kind === "Child" ? Colours.Child : Colours.Reference; + Context.lineWidth = Touching ? 1.5 : 1; + const [X1, Y1] = ToScreen(Link.SourceNode); + const [X2, Y2] = ToScreen(Link.TargetNode); + Context.beginPath(); + Context.moveTo(X1, Y1); + Context.lineTo(X2, Y2); + Context.stroke(); + } + + Context.font = `12px ${FontFamily}`; + Context.textAlign = "center"; + Context.textBaseline = "top"; + for (const Node of Nodes) { + const IsLit = !Focus || Node === Focus || Lit.has(Node); + const [X, Y] = ToScreen(Node); + if (X < -50 || Y < -50 || X > State.Width + 50 || Y > State.Height + 50) continue; + const Radius = Math.max(2, Node.Radius * Math.sqrt(State.Zoom)); + Context.globalAlpha = IsLit ? 1 : 0.2; + Context.fillStyle = Node.IsDirectory ? Colours.Directory : Colours.File; + Context.beginPath(); + Context.arc(X, Y, Radius, 0, Math.PI * 2); + Context.fill(); + if (Node === Focus) { + Context.strokeStyle = Colours.Directory; + Context.lineWidth = 2; + Context.beginPath(); + Context.arc(X, Y, Radius + 3, 0, Math.PI * 2); + Context.stroke(); + } + const ShowLabel = Focus ? IsLit : State.Zoom >= LabelZoom || Node.IsDirectory; + if (ShowLabel) { + Context.fillStyle = Node === Focus ? Colours.Text : Colours.Muted; + Context.fillText(Node.Name, X, Y + Radius + 3); + } + } + Context.globalAlpha = 1; + } + + function Loop() { + if (State.Alpha > 0.005 || State.Dragging) { + Tick(Nodes, Links, Math.max(State.Alpha, State.Dragging ? 0.1 : 0)); + State.Alpha *= 0.985; + } + Draw(); + State.Frame = requestAnimationFrame(Loop); + } + + function NodeAt(X, Y) { + let Best = null; + let BestDistance = Infinity; + for (const Node of Nodes) { + const [NX, NY] = ToScreen(Node); + const Distance = Math.hypot(NX - X, NY - Y); + const Reach = Math.max(6, Node.Radius * Math.sqrt(State.Zoom) + 4); + if (Distance < Reach && Distance < BestDistance) { + Best = Node; + BestDistance = Distance; + } + } + return Best; + } + + function LocalPoint(Event) { + const Rect = Canvas.getBoundingClientRect(); + return [Event.clientX - Rect.left, Event.clientY - Rect.top]; + } + + function OnMouseDown(Event) { + const [X, Y] = LocalPoint(Event); + const Node = NodeAt(X, Y); + State.Moved = false; + if (Node) { + State.Dragging = Node; + Node.Pinned = true; + State.Alpha = Math.max(State.Alpha, 0.3); + } else { + State.Panning = [X, Y, State.PanX, State.PanY]; + } + } + + function OnMouseMove(Event) { + const [X, Y] = LocalPoint(Event); + if (State.Dragging) { + const [WX, WY] = ToWorld(X, Y); + State.Dragging.x = WX; + State.Dragging.y = WY; + State.Moved = true; + return; + } + if (State.Panning) { + const [StartX, StartY, PanX, PanY] = State.Panning; + State.PanX = PanX + X - StartX; + State.PanY = PanY + Y - StartY; + State.Moved = State.Moved || Math.hypot(X - StartX, Y - StartY) > 3; + return; + } + State.Hovered = NodeAt(X, Y); + Canvas.style.cursor = State.Hovered ? "pointer" : "grab"; + } + + function OnMouseUp() { + const Clicked = State.Dragging && !State.Moved ? State.Dragging : null; + if (State.Dragging) State.Dragging.Pinned = false; + State.Dragging = null; + State.Panning = null; + if (Clicked) OnOpenNode(Clicked); + } + + function OnWheel(Event) { + Event.preventDefault(); + const [X, Y] = LocalPoint(Event); + const [WX, WY] = ToWorld(X, Y); + State.Zoom = Math.min(5, Math.max(0.1, State.Zoom * Math.exp(-Event.deltaY * 0.0015))); + State.PanX = X - WX * State.Zoom; + State.PanY = Y - WY * State.Zoom; + } + + function OnLeave() { + State.Hovered = null; + OnMouseUp(); + } + + Resize(); + const Observer = new ResizeObserver(Resize); + Observer.observe(Canvas); + Canvas.addEventListener("mousedown", OnMouseDown); + Canvas.addEventListener("mousemove", OnMouseMove); + window.addEventListener("mouseup", OnMouseUp); + Canvas.addEventListener("mouseleave", OnLeave); + Canvas.addEventListener("wheel", OnWheel, { passive: false }); + State.Frame = requestAnimationFrame(Loop); + + return () => { + cancelAnimationFrame(State.Frame); + Observer.disconnect(); + Canvas.removeEventListener("mousedown", OnMouseDown); + Canvas.removeEventListener("mousemove", OnMouseMove); + window.removeEventListener("mouseup", OnMouseUp); + Canvas.removeEventListener("mouseleave", OnLeave); + Canvas.removeEventListener("wheel", OnWheel); + }; + }, [Graph]); + + const FileCount = Graph ? Graph.Nodes.filter((Node) => !Node.IsDirectory).length : 0; + const DirectoryCount = Graph ? Graph.Nodes.length - FileCount : 0; + + return ( +
+
+ + + + ● Directory + ● File + ─ Reference + + {Graph && ( + + {DirectoryCount} directories, {FileCount} files + {Graph.Truncated ? " (cut short: too many to show)" : ""} + + )} +
+ {Error &&
{Error}
} + {!Graph && !Error &&
Building the graph…
} + {Graph && } +
+ ); +} diff --git a/Frontend/src/Components/Sidebar.jsx b/Frontend/src/Components/Sidebar.jsx index 913ed29..644b41f 100644 --- a/Frontend/src/Components/Sidebar.jsx +++ b/Frontend/src/Components/Sidebar.jsx @@ -286,6 +286,8 @@ export default function Sidebar({ OnEntryRenamed, OnEntryDeleted, OnOpenSettings, + OnToggleGraph, + IsGraphOpen, OnRequestLogin, OnEntryCreated, UseHyphenBullets, @@ -450,6 +452,13 @@ export default function Sidebar({ + {!IsGuest && (