const ApiBase = "/api"; async function ParseJsonOrThrow(Response) { const Body = await Response.json().catch(() => null); if (!Response.ok) { const Detail = Body && Body.detail ? Body.detail : Response.statusText; throw new Error(Detail); } return Body; } function FetchJson(Path, Options = {}) { return fetch(`${ApiBase}${Path}`, { credentials: "same-origin", ...Options }).then( ParseJsonOrThrow ); } function FetchText(Path, Options = {}) { return fetch(`${ApiBase}${Path}`, { credentials: "same-origin", ...Options }).then( (Response) => Response.text() ); } function JsonBody(Body) { return { headers: { "Content-Type": "application/json" }, body: JSON.stringify(Body) }; } export function FetchAuthStatus() { return FetchJson("/auth/status"); } export function Bootstrap(Username, Password) { return FetchJson("/auth/bootstrap", { method: "POST", ...JsonBody({ Username, Password }) }); } export function Login(Username, Password) { return FetchJson("/auth/login", { method: "POST", ...JsonBody({ Username, Password }) }); } export function Logout() { return FetchJson("/auth/logout", { method: "POST" }); } export function FetchMe() { return FetchJson("/auth/me"); } export function FetchUsers() { return FetchJson("/users"); } export function CreateUserAccount(Username, Password, IsAdmin, Colour) { return FetchJson("/users", { method: "POST", ...JsonBody({ Username, Password, IsAdmin, Colour }) }); } export function FetchProfileColourOptions() { return FetchJson("/profile/colours"); } export function UpdateProfileColour(Colour) { return FetchJson("/profile/colour", { method: "PUT", ...JsonBody({ Colour }) }); } export async function UploadAvatar(File) { const Form = new FormData(); Form.append("Avatar", File); const Response = await fetch(`${ApiBase}/profile/avatar`, { method: "POST", credentials: "same-origin", body: Form, }); return ParseJsonOrThrow(Response); } export function GetAvatarUrl(Username) { const Params = new URLSearchParams({ Username }); return `${ApiBase}/profile/avatar?${Params}`; } export function UpdateUsername(NewUsername) { return FetchJson("/profile/username", { method: "PUT", ...JsonBody({ NewUsername }) }); } export function ChangePassword(CurrentPassword, NewPassword) { return FetchJson("/profile/password", { method: "PUT", ...JsonBody({ CurrentPassword, NewPassword }), }); } export function FetchGroups() { return FetchJson("/groups"); } export function CreateGroupApi(Name) { return FetchJson("/groups", { method: "POST", ...JsonBody({ Name }) }); } export function DeleteGroupApi(Name) { const Params = new URLSearchParams({ Name }); return FetchJson(`/groups?${Params}`, { method: "DELETE" }); } export function SetGroupMembersApi(Name, Members) { const Params = new URLSearchParams({ Name }); return FetchJson(`/groups/members?${Params}`, { method: "PUT", ...JsonBody({ Members }) }); } export function FetchLocks() { return FetchJson("/locks"); } export function FetchLock(RootName, RelativePath) { const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); return FetchJson(`/lock?${Params}`); } export function FetchInheritedLock(RootName, RelativePath) { const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); return FetchJson(`/lock/inherited?${Params}`); } export function SetLock(RootName, RelativePath, Users, Groups) { const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); return FetchJson(`/lock?${Params}`, { method: "PUT", ...JsonBody({ Users, Groups }) }); } export function RemoveLock(RootName, RelativePath) { const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); return FetchJson(`/lock?${Params}`, { method: "DELETE" }); } export function FetchRoots() { return FetchJson("/roots"); } export function FetchTree(RootName, RelativePath) { const Params = new URLSearchParams({ Root: RootName, Path: 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}`); } 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}`; } export function GetRawFileUrl(RootName, RelativePath) { const EncodedSegments = RelativePath.split("/").map(encodeURIComponent); return `${ApiBase}/raw/${encodeURIComponent(RootName)}/${EncodedSegments.join("/")}`; } export function GetDownloadUrl(RootName, RelativePath) { const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); return `${ApiBase}/download?${Params}`; } // Uploads go through XMLHttpRequest: unlike fetch, it reports how much has // been sent. OnProgress(BytesSent) is optional. function PostFormWithProgress(Url, Form, OnProgress) { return new Promise((Resolve, Reject) => { const Request = new XMLHttpRequest(); Request.open("POST", Url); Request.withCredentials = true; if (OnProgress) { Request.upload.onprogress = (Event) => OnProgress(Event.loaded); } Request.onload = () => { let Body = null; try { Body = JSON.parse(Request.responseText); } catch { Body = null; } if (Request.status >= 200 && Request.status < 300) { Resolve(Body); } else { Reject(new Error(Body && Body.detail ? Body.detail : Request.statusText || `Upload failed (${Request.status})`)); } }; Request.onerror = () => Reject(new Error("Upload failed: couldn't reach the server.")); Request.send(Form); }); } export function UploadFiles(RootName, RelativePath, Files, OnProgress) { const Form = new FormData(); for (const File of Files) { Form.append("Files", File); } const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); return PostFormWithProgress(`${ApiBase}/upload?${Params}`, Form, OnProgress); } const UploadChunkSizeBytes = 20 * 1024 * 1024; export const ChunkedUploadThresholdBytes = 40 * 1024 * 1024; export function IsLargeUpload(File) { return File.size > ChunkedUploadThresholdBytes; } function GenerateUploadId() { if (window.crypto && window.crypto.randomUUID) { return window.crypto.randomUUID(); } return `${Date.now()}-${Math.random().toString(16).slice(2)}`; } function UploadFileChunk(RootName, RelativePath, UploadId, ChunkIndex, TotalChunks, FileName, ChunkBlob, OnProgress) { const Form = new FormData(); Form.append("Chunk", ChunkBlob, "chunk"); const Params = new URLSearchParams({ Root: RootName, Path: RelativePath, UploadId, ChunkIndex: String(ChunkIndex), TotalChunks: String(TotalChunks), FileName, }); return PostFormWithProgress(`${ApiBase}/upload/chunk?${Params}`, Form, OnProgress); } function CompleteChunkedUpload(RootName, RelativePath, UploadId, TotalChunks, FileName) { const Params = new URLSearchParams({ Root: RootName, Path: RelativePath, UploadId, TotalChunks: String(TotalChunks), FileName, }); return FetchJson(`/upload/complete?${Params}`, { method: "POST" }); } // OnProgress(BytesSent) counts across all the chunks. export async function UploadFileChunked(RootName, RelativePath, File, OnProgress) { const UploadId = GenerateUploadId(); const TotalChunks = Math.max(1, Math.ceil(File.size / UploadChunkSizeBytes)); for (let ChunkIndex = 0; ChunkIndex < TotalChunks; ChunkIndex++) { const Start = ChunkIndex * UploadChunkSizeBytes; const ChunkBlob = File.slice(Start, Start + UploadChunkSizeBytes); await UploadFileChunk(RootName, RelativePath, UploadId, ChunkIndex, TotalChunks, File.name, ChunkBlob, OnProgress && ((Sent) => OnProgress(Start + Math.min(Sent, ChunkBlob.size)))); } return CompleteChunkedUpload(RootName, RelativePath, UploadId, TotalChunks, File.name); } // Where each variable (portal, definition, loop) is in Content, and what it // resolves to: the live editor shows the results in place (LivePreview.js). export function FetchSpans(RootName, RelativePath, Content) { const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); return FetchJson(`/file/spans?${Params}`, { method: "POST", ...JsonBody({ Content }) }); } export function SaveFile(RootName, RelativePath, Content) { const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); return FetchJson(`/file?${Params}`, { method: "PUT", body: Content }); } export function FetchBook(RootName, RelativePath) { const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); return FetchJson(`/book?${Params}`); } export function FetchBlame(RootName, RelativePath) { const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); return FetchJson(`/blame?${Params}`); } export function CreateEntry(RootName, RelativePath, IsDirectory) { const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); return FetchJson(`/tree?${Params}`, { method: "POST", ...JsonBody({ IsDirectory }) }); } export function RenameEntry(RootName, RelativePath, NewName) { const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); return FetchJson(`/tree?${Params}`, { method: "PATCH", ...JsonBody({ NewName }) }); } export function MoveEntry(RootName, RelativePath, DestinationPath) { const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); return FetchJson(`/tree?${Params}`, { method: "PATCH", ...JsonBody({ DestinationPath }) }); } export function DeleteEntry(RootName, RelativePath) { const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); return FetchJson(`/tree?${Params}`, { method: "DELETE" }); } export function EnsureDirectoryNote(RootName, RelativePath) { const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); return FetchJson(`/note?${Params}`, { method: "POST" }); } export function FetchSettings() { return FetchJson("/settings"); } export function SaveSettings(NewSettings) { return FetchJson("/settings", { method: "PUT", ...JsonBody(NewSettings) }); } export function FetchThemeNames() { return FetchJson("/themes"); } export function FetchTheme(Name) { const Params = new URLSearchParams({ Name }); return FetchJson(`/theme?${Params}`); } export function SaveTheme(Name, Palette) { const Params = new URLSearchParams({ Name }); return FetchJson(`/theme?${Params}`, { method: "PUT", ...JsonBody({ Palette }) }); } export function DeleteTheme(Name) { const Params = new URLSearchParams({ Name }); return FetchJson(`/theme?${Params}`, { method: "DELETE" }); }