export type DriveRootKind = "personal" | "org" | "mount" | "shared" export type DriveView = | "files" | "shared" | "org" | "mount" | "recent" | "starred" | "trash" | "search" /** Decode one URL path segment (safe for names with literal `%`). */ export function decodePathSegment(segment: string): string { try { return decodeURIComponent(segment.replace(/\+/g, " ")) } catch { return segment } } function decodePathSegments(segments: string[]): string[] { return segments.map(decodePathSegment) } export function encodePathSegments(segments: string[]): string { return segments.map((s) => encodeURIComponent(decodePathSegment(s))).join("/") } export function driveRouteBase(routeRoot = "drive"): string { return `/${routeRoot}` } export function buildDriveFolderHref( view: Extract, segments: string[], rootId?: string, routeRoot = "drive" ): string { const base = driveRouteBase(routeRoot) const decoded = decodePathSegments(segments) if (view === "org" && rootId) { if (decoded.length === 0) return `${base}/org/${encodeURIComponent(rootId)}` return `${base}/org/${encodeURIComponent(rootId)}/folders/${encodePathSegments(decoded)}` } if (view === "mount" && rootId) { if (decoded.length === 0) return `${base}/mounts/${encodeURIComponent(rootId)}` return `${base}/mounts/${encodeURIComponent(rootId)}/folders/${encodePathSegments(decoded)}` } if (decoded.length === 0) { return view === "shared" ? `${base}/shared` : base } const prefix = view === "shared" ? `${base}/shared/folders` : `${base}/folders` return `${prefix}/${encodePathSegments(decoded)}` } export interface DriveRouteState { view: DriveView pathSegments: string[] page: number fileId: string | null query: string rootId: string | null } export function parseDriveSegments(segments: string[] | undefined): DriveRouteState { const parts = segments ?? [] if (parts.length === 0) { return { view: "files", pathSegments: [], page: 1, fileId: null, query: "", rootId: null } } const head = parts[0] if (head === "recent" || head === "starred" || head === "trash") { return { view: head, pathSegments: [], page: 1, fileId: null, query: "", rootId: null } } if (head === "org" && parts[1]) { const folderId = decodePathSegment(parts[1]) const folderParts = parts.slice(2) if (folderParts[0] === "folders") { folderParts.shift() const pageIdx = folderParts.indexOf("page") let page = 1 if (pageIdx >= 0 && folderParts[pageIdx + 1]) { page = Math.max(1, parseInt(folderParts[pageIdx + 1], 10) || 1) folderParts.splice(pageIdx, 2) } return { view: "org", pathSegments: decodePathSegments(folderParts), page, fileId: null, query: "", rootId: folderId, } } return { view: "org", pathSegments: [], page: 1, fileId: null, query: "", rootId: folderId } } if (head === "mounts" && parts[1]) { const mountId = decodePathSegment(parts[1]) const folderParts = parts.slice(2) if (folderParts[0] === "folders") { folderParts.shift() const pageIdx = folderParts.indexOf("page") let page = 1 if (pageIdx >= 0 && folderParts[pageIdx + 1]) { page = Math.max(1, parseInt(folderParts[pageIdx + 1], 10) || 1) folderParts.splice(pageIdx, 2) } return { view: "mount", pathSegments: decodePathSegments(folderParts), page, fileId: null, query: "", rootId: mountId, } } return { view: "mount", pathSegments: [], page: 1, fileId: null, query: "", rootId: mountId } } if (head === "shared") { const folderParts = parts.slice(1) let page = 1 if (folderParts[0] === "folders") { folderParts.shift() const pageIdx = folderParts.indexOf("page") if (pageIdx >= 0 && folderParts[pageIdx + 1]) { page = Math.max(1, parseInt(folderParts[pageIdx + 1], 10) || 1) folderParts.splice(pageIdx, 2) } return { view: "shared", pathSegments: decodePathSegments(folderParts), page, fileId: null, query: "", rootId: null, } } return { view: "shared", pathSegments: [], page: 1, fileId: null, query: "", rootId: null } } if (head === "search") { return { view: "search", pathSegments: [], page: 1, fileId: null, query: "", rootId: null } } if (head === "edit" && parts[1]) { return { view: "files", pathSegments: [], page: 1, fileId: decodeURIComponent(parts[1]), query: "", rootId: null, } } if (head === "folders") { const folderParts = parts.slice(1) let page = 1 const pageIdx = folderParts.indexOf("page") if (pageIdx >= 0 && folderParts[pageIdx + 1]) { page = Math.max(1, parseInt(folderParts[pageIdx + 1], 10) || 1) folderParts.splice(pageIdx, 2) } return { view: "files", pathSegments: decodePathSegments(folderParts), page, fileId: null, query: "", rootId: null, } } return { view: "files", pathSegments: decodePathSegments(parts), page: 1, fileId: null, query: "", rootId: null, } } export function buildDrivePath(state: DriveRouteState, routeRoot = "drive"): string { const base = driveRouteBase(routeRoot) if (state.view === "recent") return `${base}/recent` if (state.view === "starred") return `${base}/starred` if (state.view === "trash") return `${base}/trash` if (state.view === "search") return `${base}/search` if (state.view === "org" && state.rootId) { const folder = state.pathSegments.length ? `${base}/org/${encodeURIComponent(state.rootId)}/folders/${encodePathSegments(state.pathSegments)}` : `${base}/org/${encodeURIComponent(state.rootId)}` const pageSuffix = state.page > 1 ? `/page/${state.page}` : "" return `${folder}${pageSuffix}` } if (state.view === "mount" && state.rootId) { const folder = state.pathSegments.length ? `${base}/mounts/${encodeURIComponent(state.rootId)}/folders/${encodePathSegments(state.pathSegments)}` : `${base}/mounts/${encodeURIComponent(state.rootId)}` const pageSuffix = state.page > 1 ? `/page/${state.page}` : "" return `${folder}${pageSuffix}` } if (state.view === "shared") { const folder = state.pathSegments.length ? `${base}/shared/folders/${encodePathSegments(state.pathSegments)}` : `${base}/shared` const pageSuffix = state.page > 1 ? `/page/${state.page}` : "" return `${folder}${pageSuffix}` } if (state.fileId && state.view === "files") { return `${base}/edit/${encodeURIComponent(state.fileId)}` } const folder = state.pathSegments.length ? `${base}/folders/${encodePathSegments(state.pathSegments)}` : base const pageSuffix = state.page > 1 ? `/page/${state.page}` : "" return `${folder}${pageSuffix}` } export function folderPathFromSegments(segments: string[]): string { const decoded = decodePathSegments(segments) if (decoded.length === 0) return "/" return "/" + decoded.join("/") } export function parentFolderPathFromFilePath(filePath: string): string { const normalized = filePath.replace(/^\/+/, "").replace(/\/+$/, "") if (!normalized) return "/" const parts = normalized.split("/") if (parts.length <= 1) return "/" return "/" + parts.slice(0, -1).join("/") } function isSafeDriveReturnPath(path: string): boolean { if (!path.startsWith("/drive") && !path.startsWith("/demo/drive")) return false if (path.startsWith("//")) return false if (path.includes("://")) return false return true } /** Stable Nextcloud file id segment (numeric). */ export function isDriveFileIdSegment(value: string): boolean { return /^[1-9][0-9]*$/.test(value) } /** Rich-text editor URL — id-only, no returnTo query param. */ export function buildDriveDocsEditHref(fileId: string | number): string { return `/drive/docs/${String(fileId)}/edit` } /** UltiDraw (Excalidraw) editor URL — id-only. */ export function buildDriveDrawEditHref(fileId: string | number): string { return `/drive/draw/${String(fileId)}/edit` } export function buildDriveEditHref(filePath: string, returnTo?: string): string { const params = new URLSearchParams() if (returnTo && isSafeDriveReturnPath(returnTo)) { params.set("returnTo", returnTo) } const base = `/drive/edit/${encodeURIComponent(filePath)}` const qs = params.toString() return qs ? `${base}?${qs}` : base } /** Resolve back link from editor: prefer session returnTo, else explicit returnTo, else parent folder. */ export function resolveDriveEditReturnTo( returnTo: string | null | undefined, filePath: string, folderHref: (folderPath: string) => string, sessionReturnTo?: string | null ): string { if (sessionReturnTo && isSafeDriveReturnPath(sessionReturnTo)) return sessionReturnTo if (returnTo && isSafeDriveReturnPath(returnTo)) return returnTo return folderHref(parentFolderPathFromFilePath(filePath)) } export function driveRootKindForView(view: DriveView): DriveRootKind { if (view === "org") return "org" if (view === "mount") return "mount" if (view === "shared") return "shared" return "personal" }