mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-14 02:38:11 +02:00
Added more markdown syntax, and added portals.
This commit is contained in:
1 parent
ee7e163837
commit
9dba722cd9
6 files changed
+349
-8
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user