From 9dba722cd9c0584c9106ffe8523e751012e21ab0 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Mon, 21 Sep 2026 18:24:08 +0200 Subject: [PATCH] Added more markdown syntax, and added portals. --- Backend/Main.py | 27 +++ Backend/Portals.py | 251 ++++++++++++++++++++++++ Frontend/package.json | 4 +- Frontend/src/Api.js | 5 + Frontend/src/Components/PreviewPane.jsx | 33 +++- Frontend/src/Styles/Theme.css | 37 ++++ 6 files changed, 349 insertions(+), 8 deletions(-) create mode 100644 Backend/Portals.py diff --git a/Backend/Main.py b/Backend/Main.py index 52d5d23..f11b922 100644 --- a/Backend/Main.py +++ b/Backend/Main.py @@ -42,6 +42,7 @@ from Locks import ( RequirePathAccessible, SetLock, ) +from Portals import ResolvePortals from Preview import GetFileSize, GetMimeType, ParseRangeHeader, ReadTextFile, TextFileTypes from Settings import ( DeleteTheme, @@ -517,6 +518,32 @@ def GetFileEndpoint( return StreamingResponse(IterateFile(), media_type=MimeType, headers=Headers) +@Application.get("/api/file/resolved") +def GetResolvedFileEndpoint( + Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser) +) -> dict: + RequirePathAccessible(Root, Path, CurrentUser) + + try: + FilePath = ResolvePath(Root, Path) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + + if not FilePath.is_file(): + raise HTTPException(status_code=404, detail="File not found.") + + FileType = ClassifyFile(FilePath) + + if FileType not in TextFileTypes: + raise HTTPException(status_code=400, detail="Not a text file.") + + Content = ReadTextFile(FilePath) + Resolved = ResolvePortals( + Root, Path, Content, CurrentUser["Username"], CurrentUser.get("Groups", []) + ) + return {"Content": Resolved} + + @Application.put("/api/file") async def PutFileEndpoint( Root: str, Path: str, request: Request, CurrentUser: dict = Depends(GetCurrentUser) diff --git a/Backend/Portals.py b/Backend/Portals.py new file mode 100644 index 0000000..4882a6a --- /dev/null +++ b/Backend/Portals.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +import re + +from Core import ResolvePath +from Explorer import ClassifyFile, ListDirectory +from Locks import IsPathAccessible +from Preview import ReadTextFile + + +PortalFileTypes = {"markdown", "text"} + +MarkerPattern = re.compile( + r"\{(?:(?P[^\s{}']+)\s+)?\*\*\*(?P[^\s*{}]+)\}" + r"|\{(?P[^\s{}']+)\s+'(?P\d+)'\}" + r"|\{\*\*(?P[^\s*{}]+)\}" + r"|\{\*(?P[^\s*{}]+)\}" +) + + +def _LineBounds(Content: str, Position: int) -> tuple[int, int]: + LineStart = Content.rfind("\n", 0, Position) + 1 + LineEnd = Content.find("\n", Position) + if LineEnd == -1: + LineEnd = len(Content) + return LineStart, LineEnd + + +def _FindLocalDefinitions(Content: str) -> dict[str, str]: + Matches = list(MarkerPattern.finditer(Content)) + + LineDefinitions: dict[str, str] = {} + BlockOccurrences: dict[str, list[re.Match]] = {} + + for Match in Matches: + if Match.group("LineName"): + Name = Match.group("LineName") + if Name not in LineDefinitions: + LineStart, LineEnd = _LineBounds(Content, Match.start()) + Line = Content[LineStart:LineEnd] + Captured = ( + Line[: Match.start() - LineStart] + Line[Match.end() - LineStart :] + ).strip() + LineDefinitions[Name] = Captured + elif Match.group("BlockName"): + Name = Match.group("BlockName") + BlockOccurrences.setdefault(Name, []).append(Match) + + BlockDefinitions: dict[str, str] = {} + for Name, Occurrences in BlockOccurrences.items(): + Open = Occurrences[0] + if len(Occurrences) >= 2: + Close = Occurrences[1] + BlockDefinitions[Name] = Content[Open.end() : Close.start()].strip("\n") + else: + BlockDefinitions[Name] = Content[Open.end() :].lstrip("\n") + + return {**LineDefinitions, **BlockDefinitions} + + +def _ResolveLocalPortals(Content: str) -> str: + Matches = list(MarkerPattern.finditer(Content)) + Definitions = _FindLocalDefinitions(Content) + + LineSeenCount: dict[str, int] = {} + BlockSeenCount: dict[str, int] = {} + + Output: list[str] = [] + Cursor = 0 + + for Match in Matches: + Replacement = None + + if Match.group("LineName"): + Name = Match.group("LineName") + Occurrence = LineSeenCount.get(Name, 0) + LineSeenCount[Name] = Occurrence + 1 + Replacement = "" if Occurrence == 0 else Definitions.get(Name, "") + elif Match.group("BlockName"): + Name = Match.group("BlockName") + Occurrence = BlockSeenCount.get(Name, 0) + BlockSeenCount[Name] = Occurrence + 1 + Replacement = "" if Occurrence in (0, 1) else Definitions.get(Name, "") + + if Replacement is not None: + Output.append(Content[Cursor : Match.start()]) + Output.append(Replacement) + Cursor = Match.end() + + Output.append(Content[Cursor:]) + return "".join(Output) + + +def _NormalizeRelativePath(BaseDirectory: str, RawPath: str) -> str: + Combined = f"{BaseDirectory}/{RawPath}" if BaseDirectory else RawPath + Parts: list[str] = [] + + for Part in Combined.split("/"): + if Part in ("", "."): + continue + if Part == "..": + if Parts: + Parts.pop() + continue + Parts.append(Part) + + return "/".join(Parts) + + +def _ReadAccessiblePortalSource( + RootName: str, RelativePath: str, Username: str, UserGroups: list[str] +) -> str | None: + if not IsPathAccessible(RootName, RelativePath, Username, UserGroups): + return None + + try: + FilePath = ResolvePath(RootName, RelativePath) + except ValueError: + return None + + if not FilePath.is_file() or ClassifyFile(FilePath) not in PortalFileTypes: + return None + + try: + return ReadTextFile(FilePath) + except (OSError, UnicodeDecodeError): + return None + + +def _FindNamedPortalInDirectory( + RootName: str, DirectoryRelativePath: str, Name: str, Username: str, UserGroups: list[str] +) -> str | None: + if not IsPathAccessible(RootName, DirectoryRelativePath, Username, UserGroups): + return None + + try: + Entries = ListDirectory(RootName, DirectoryRelativePath, False, False) + except (ValueError, NotADirectoryError, FileNotFoundError): + return None + + for Entry in Entries: + if Entry["IsDirectory"] or Entry["FileType"] not in PortalFileTypes: + continue + + EntryPath = ( + f"{DirectoryRelativePath}/{Entry['Name']}" if DirectoryRelativePath else Entry["Name"] + ) + Content = _ReadAccessiblePortalSource(RootName, EntryPath, Username, UserGroups) + + if Content is None: + continue + + Definitions = _FindLocalDefinitions(Content) + if Name in Definitions: + return Definitions[Name] + + return None + + +def _ResolveCrossFileReference( + RootName: str, + RefererDirectory: str, + PathText: str | None, + Name: str, + Username: str, + UserGroups: list[str], +) -> str | None: + if not PathText: + return _FindNamedPortalInDirectory(RootName, RefererDirectory, Name, Username, UserGroups) + + TargetRelativePath = _NormalizeRelativePath(RefererDirectory, PathText) + + try: + TargetPath = ResolvePath(RootName, TargetRelativePath) + except ValueError: + return None + + if TargetPath.is_dir(): + return _FindNamedPortalInDirectory( + RootName, TargetRelativePath, Name, Username, UserGroups + ) + + Content = _ReadAccessiblePortalSource(RootName, TargetRelativePath, Username, UserGroups) + if Content is None: + return None + + return _FindLocalDefinitions(Content).get(Name) + + +def _ResolveLineNumberReference( + RootName: str, + RefererDirectory: str, + PathText: str, + LineNumber: int, + Username: str, + UserGroups: list[str], +) -> str | None: + TargetRelativePath = _NormalizeRelativePath(RefererDirectory, PathText) + Content = _ReadAccessiblePortalSource(RootName, TargetRelativePath, Username, UserGroups) + + if Content is None: + return None + + Lines = Content.splitlines() + Index = LineNumber - 1 + + if 0 <= Index < len(Lines): + return Lines[Index] + + return None + + +def ResolvePortals( + RootName: str, RelativePath: str, Content: str, Username: str, UserGroups: list[str] +) -> str: + LocallyResolved = _ResolveLocalPortals(Content) + RefererDirectory = RelativePath.rsplit("/", 1)[0] if "/" in RelativePath else "" + + Matches = list(MarkerPattern.finditer(LocallyResolved)) + Output: list[str] = [] + Cursor = 0 + + for Match in Matches: + Replacement = None + + if Match.group("CrossName"): + Replacement = _ResolveCrossFileReference( + RootName, + RefererDirectory, + Match.group("CrossPath"), + Match.group("CrossName"), + Username, + UserGroups, + ) + elif Match.group("LineNumber"): + Replacement = _ResolveLineNumberReference( + RootName, + RefererDirectory, + Match.group("LinePath"), + int(Match.group("LineNumber")), + Username, + UserGroups, + ) + + if Replacement is not None: + Output.append(LocallyResolved[Cursor : Match.start()]) + Output.append(Replacement) + Cursor = Match.end() + + Output.append(LocallyResolved[Cursor:]) + return "".join(Output) diff --git a/Frontend/package.json b/Frontend/package.json index 234aac1..88b8ceb 100644 --- a/Frontend/package.json +++ b/Frontend/package.json @@ -17,7 +17,9 @@ "@codemirror/lang-html": "^6.4.9", "@codemirror/lang-json": "^6.0.1", "markdown-it": "^14.1.0", - "markdown-it-task-lists": "^2.1.1" + "markdown-it-task-lists": "^2.1.1", + "markdown-it-mark": "^4.0.0", + "markdown-it-footnote": "^4.0.0" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.1", diff --git a/Frontend/src/Api.js b/Frontend/src/Api.js index d14fead..067f4a6 100644 --- a/Frontend/src/Api.js +++ b/Frontend/src/Api.js @@ -141,6 +141,11 @@ export function FetchFile(RootName, RelativePath) { return FetchText(`/file?${Params}`); } +export function FetchResolvedFile(RootName, RelativePath) { + const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); + return FetchJson(`/file/resolved?${Params}`); +} + export function GetFileUrl(RootName, RelativePath) { const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); return `${ApiBase}/file?${Params}`; diff --git a/Frontend/src/Components/PreviewPane.jsx b/Frontend/src/Components/PreviewPane.jsx index f9f98d7..a4da8f0 100644 --- a/Frontend/src/Components/PreviewPane.jsx +++ b/Frontend/src/Components/PreviewPane.jsx @@ -6,13 +6,23 @@ import { html } from "@codemirror/lang-html"; import { json } from "@codemirror/lang-json"; import MarkdownIt from "markdown-it"; import MarkdownItTaskLists from "markdown-it-task-lists"; +import MarkdownItMark from "markdown-it-mark"; +import MarkdownItFootnote from "markdown-it-footnote"; -import { FetchBlame, FetchFile, GetAvatarUrl, GetFileUrl, GetRawFileUrl, SaveFile } from "../Api.js"; +import { + FetchBlame, + FetchFile, + FetchResolvedFile, + GetAvatarUrl, + GetFileUrl, + GetRawFileUrl, + SaveFile, +} from "../Api.js"; -const MarkdownRenderer = new MarkdownIt({ breaks: true }).use(MarkdownItTaskLists, { - enabled: true, - label: true, -}); +const MarkdownRenderer = new MarkdownIt({ breaks: true }) + .use(MarkdownItTaskLists, { enabled: true, label: true }) + .use(MarkdownItMark) + .use(MarkdownItFootnote); const DefaultImageRenderer = MarkdownRenderer.renderer.rules.image || @@ -162,11 +172,18 @@ export default function PreviewPane({ StickyToolbar, }) { const [Content, SetContent] = useState(""); + const [ResolvedContent, SetResolvedContent] = useState(""); const [IsEditing, SetIsEditing] = useState(false); const [IsSaving, SetIsSaving] = useState(false); const [SaveError, SetSaveError] = useState(null); const [BlameMap, SetBlameMap] = useState({}); + function RefreshResolvedContent() { + FetchResolvedFile(RootName, FilePath) + .then((Result) => SetResolvedContent(Result.Content)) + .catch(() => {}); + } + useEffect(() => { SetIsEditing(false); SetSaveError(null); @@ -174,6 +191,7 @@ export default function PreviewPane({ if (FilePath && TextTypes.includes(FileType)) { FetchFile(RootName, FilePath).then(SetContent); + RefreshResolvedContent(); if (ShowGitBlame) { FetchBlame(RootName, FilePath) @@ -194,6 +212,7 @@ export default function PreviewPane({ SetSaveError(null); try { await SaveFile(RootName, FilePath, Content); + RefreshResolvedContent(); if (ShowGitBlame) { FetchBlame(RootName, FilePath) .then(SetBlameMap) @@ -257,7 +276,7 @@ export default function PreviewPane({ className="MarkdownBody" onClick={HandleMarkdownClick} dangerouslySetInnerHTML={{ - __html: MarkdownRenderer.render(Content, { + __html: MarkdownRenderer.render(ResolvedContent, { ResolveImageSrc: (Src) => { const ResolvedPath = ResolveRelativeLink(FilePath, Src); return ResolvedPath === null ? Src : GetFileUrl(RootName, ResolvedPath); @@ -275,7 +294,7 @@ export default function PreviewPane({
-
{Content}
+
{ResolvedContent}
); } diff --git a/Frontend/src/Styles/Theme.css b/Frontend/src/Styles/Theme.css index 61b3247..f9bf81f 100644 --- a/Frontend/src/Styles/Theme.css +++ b/Frontend/src/Styles/Theme.css @@ -413,6 +413,43 @@ img, gap: 6px; } +.MarkdownBody mark { + background: var(--Yellow); + color: var(--Black); + border-radius: 2px; + padding: 0 2px; +} + +.MarkdownBody table { + border-collapse: collapse; + width: 100%; + margin: 12px 0; +} + +.MarkdownBody th, +.MarkdownBody td { + border: 1px solid var(--Border); + padding: 6px 10px; + text-align: left; +} + +.MarkdownBody th { + background: var(--SurfaceAlt); +} + +.MarkdownBody .footnotes { + margin-top: 24px; + padding-top: 12px; + border-top: 1px solid var(--Border); + font-size: 0.9em; + color: var(--TextMuted); +} + +.MarkdownBody .footnote-ref a, +.MarkdownBody .footnote-backref { + color: var(--Blue); +} + .PreviewText { flex: 1; min-width: 0;