Added more markdown syntax, and added portals.

This commit is contained in:
sarah committed 2026-09-21 18:24:08 +02:00
1 parent ee7e163837
commit 9dba722cd9
6 files changed
+349 -8

No files matched your search

+27
View File
@@ -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)
+251
View File
@@ -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<CrossPath>[^\s{}']+)\s+)?\*\*\*(?P<CrossName>[^\s*{}]+)\}"
r"|\{(?P<LinePath>[^\s{}']+)\s+'(?P<LineNumber>\d+)'\}"
r"|\{\*\*(?P<LineName>[^\s*{}]+)\}"
r"|\{\*(?P<BlockName>[^\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)
+3 -1
View File
@@ -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",
+5
View File
@@ -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}`;
+26 -7
View File
@@ -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({
<div className={ToolbarClass}>
<button onClick={() => SetIsEditing(true)}>Edit</button>
</div>
<pre className="TextBody">{Content}</pre>
<pre className="TextBody">{ResolvedContent}</pre>
</div>
);
}
+37
View File
@@ -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;