/** * WebDAV integration for the Meatloaf Manipulator UI. * * Protocol layer is provided by the vendored `webdav-component` * (see src/app/vendor/webdav-component). We deliberately do NOT use * its `WebDAVUI` — the React components in this app render their own * file browser, search, and directory listings. * * Public surface (kept stable for our own React components): * getWebDAVClient() – lazy WebDAVManager (high-level state + actions) * getWebDAVBaseUrl() – the server base URL we're talking to * listDirectory(p) – returns EntryInfo[] for a collection * fileExists(p) – boolean * createFolder(p) – create a new (possibly nested) collection * deletePath(p) – delete a file or (empty) collection * movePath(from, to) – MOVE * putFileContents(p, data) – upload * getFileContents(p) – download as Blob * humanFileSize(n) – "1.4 MB" formatter * normalizePath / splitPath / joinPath / basename – path helpers */ import { WebDAVManager, PROPFIND_LIST_BODY, type WebDAVEntry, } from './vendor/webdav-component/esm/index.js'; export type { WebDAVEntry }; // ----- Connection settings ---------------------------------------------- const DEFAULT_BASE_URL = (typeof import.meta !== 'undefined' && (import.meta as any).env?.VITE_WEBDAV_URL) || (typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.hostname}` : 'http://localhost'); let _manager: WebDAVManager | null = null; export function getWebDAVBaseUrl(): string { return DEFAULT_BASE_URL; } export function getWebDAVClient(): WebDAVManager { if (!_manager) { _manager = new WebDAVManager({ url: DEFAULT_BASE_URL, }); } return _manager; } // ----- Path helpers (kept stable for our React UI) -------------------- /** Normalize a path so it always starts with `/` and has no trailing `/`. */ export function normalizePath(p: string): string { if (!p) return '/'; let s = p.trim(); if (!s.startsWith('/')) s = '/' + s; s = s.replace(/\/+/g, '/'); if (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1); return s || '/'; } /** Split a path into parent + name. */ export function splitPath(p: string): { parent: string; name: string } { const n = normalizePath(p); if (n === '/') return { parent: '/', name: '' }; const idx = n.lastIndexOf('/'); if (idx === 0) return { parent: '/', name: n.slice(1) }; return { parent: n.slice(0, idx), name: n.slice(idx + 1) }; } /** Join a parent path and a child name. */ export function joinPath(parent: string, name: string): string { if (parent === '/' || parent === '') return '/' + name; return (parent.endsWith('/') ? parent : parent + '/') + name; } /** Last segment of a path. */ export function basename(p: string): string { return splitPath(p).name; } // ----- Mapping webdav-component entries to our UI's EntryInfo ----------- export type EntryType = 'folder' | 'file'; export interface EntryInfo { name: string; /** Absolute path on the WebDAV server (always begins with `/`). */ path: string; type: EntryType; /** Bytes, or 0 for collections. */ size: number; lastModified: Date | null; contentType: string | null; } /** * Build an absolute WebDAV path (with leading `/`) for a relative name * under a given collection URL. The vendored `WebDAVManager` operates on * full URLs, so we keep URL-relative work in `manager.open()` and * path-relative work everywhere else. */ function pathFromUri(uri: string, baseUrl: string): string { let p: string; try { // Absolute URL → take just the pathname. if (/^https?:\/\//i.test(uri)) { const u = new URL(uri); p = u.pathname; } else { p = uri; } } catch { p = uri; } // Strip a host prefix if the server added one (e.g. lighttpd). try { const host = new URL(baseUrl).hostname; if (p.startsWith('/' + host + '/')) p = p.slice(host.length + 1); } catch { /* ignore */ } // Always URL-decode so the rest of the app sees literal characters // (e.g. spaces) instead of %20 in names and paths. try { p = decodeURIComponent(p); } catch { /* leave as-is on malformed encoding */ } return normalizePath(p); } function toEntryInfo(e: WebDAVEntry, baseUrl: string): EntryInfo { // The vendored `webdav-component` computes `entry.path` by stripping // `baseUrl.length` from the href — that only works when the server // sends absolute hrefs (e.g. `http://localhost/sd/foo`). When the // server sends relative hrefs (`/sd/foo`), the strip chops off the // start of the path. Use `entry.uri` directly instead, which the // parser sets verbatim from the href and is always reliable. const rawPath = (e.uri && e.uri.length > 0 ? e.uri : e.path) || ''; const fullPath = pathFromUri(rawPath, baseUrl); // Prefer the server-provided displayname when it looks like a leaf // (no slashes). Some servers (notably older webdav3.py) used to // return the full path as the displayname; in that case fall back to // the leaf of the resolved path. const looksLikePath = (s: string) => s.includes('/'); const name = (e.name && !looksLikePath(e.name) ? e.name : '') || basename(fullPath) || e.name; return { name, path: fullPath, type: e.isDir ? 'folder' : 'file', size: typeof e.size === 'number' ? e.size : 0, lastModified: e.modified ?? null, contentType: e.mime ?? null, }; } // ----- High-level operations ------------------------------------------- /** * List the contents of a collection. Returns a sorted array of entries. * Pass `recursive: true` to request `Depth: infinity` from the server and * return all descendants flattened — requires the server to support it * (webdav3.py does after the depth-infinity fix). */ export async function listDirectory( path: string, recursive = false, onProgress?: (bytes: number) => void, ): Promise { const manager = getWebDAVClient(); const base = manager.client.baseUrl; const collectionUrl = pathToUrl(normalizePath(path), base); const selfPath = normalizePath(path); // eslint-disable-next-line @typescript-eslint/no-explicit-any const depth: any = recursive ? 'infinity' : 1; let doc: Document; if (onProgress) { const response = await fetch(collectionUrl, { method: 'PROPFIND', headers: { 'Depth': String(depth), 'Content-Type': 'text/xml; charset=utf-8' }, body: PROPFIND_LIST_BODY, }); if (!response.body) throw new Error('No response body'); const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let received = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); received += value.byteLength; onProgress(received); } const combined = new Uint8Array(received); let offset = 0; for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; } doc = new DOMParser().parseFromString(new TextDecoder().decode(combined), 'text/xml'); } else { doc = await manager.client.propfind(collectionUrl, PROPFIND_LIST_BODY, depth); } const entries: EntryInfo[] = []; for (const node of Array.from(doc.querySelectorAll('response'))) { const hrefEl = node.querySelector('href'); if (!hrefEl?.textContent) continue; // Find the first 200 propstat. let propsNode: Element | null = null; for (const propstat of Array.from(node.querySelectorAll('propstat'))) { const status = propstat.querySelector('status'); if (status && /200/.test(status.textContent ?? '')) { propsNode = propstat; break; } } if (!propsNode) continue; const fullPath = pathFromUri(hrefEl.textContent, base); // Skip the collection itself. if (fullPath === selfPath) continue; // Non-recursive: only include direct children. if (!recursive && splitPath(fullPath).parent !== selfPath) continue; const isDir = !!node.querySelector('resourcetype collection'); const displaynameEl = propsNode.querySelector('displayname'); const lengthEl = propsNode.querySelector('getcontentlength'); const mimeEl = propsNode.querySelector('getcontenttype'); const modifiedEl = propsNode.querySelector('getlastmodified'); // Prefer server displayname unless it looks like a full path. const nameFromPath = basename(fullPath) || fullPath; const dn = displaynameEl?.textContent ?? ''; const name = dn && !dn.includes('/') ? dn : nameFromPath; entries.push({ name, path: fullPath, type: isDir ? 'folder' : 'file', size: !isDir && lengthEl?.textContent ? parseInt(lengthEl.textContent, 10) : 0, lastModified: modifiedEl?.textContent ? new Date(modifiedEl.textContent) : null, contentType: !isDir && mimeEl?.textContent ? mimeEl.textContent : null, }); } // Folders first, then alpha (case-insensitive). entries.sort((a, b) => { if (a.type !== b.type) return a.type === 'folder' ? -1 : 1; return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); }); return entries; } export async function fileExists(path: string): Promise { const manager = getWebDAVClient(); const base = manager.client.baseUrl; return manager.client.exists(pathToUrl(normalizePath(path), base)); } /** * Probe a path via a depth-0 PROPFIND to determine whether it is a file * or a folder, and return its parsed `WebDAVEntry`. Returns `null` if the * path does not exist (or the server returns 404). */ export async function stat(path: string): Promise { const manager = getWebDAVClient(); const base = manager.client.baseUrl; const url = pathToUrl(normalizePath(path), base); try { const doc = await manager.client.propfind(url, PROPFIND_LIST_BODY, 0); const resp = doc.querySelector('response'); if (!resp) return null; const isDir = !!doc.querySelector('resourcetype collection'); // We re-use the same path-resolution logic as `toEntryInfo` to keep // naming and path handling consistent. const href = doc.querySelector('href')?.textContent ?? url; const fakeEntry = { uri: href, url: href, path: href, name: doc.querySelector('displayname')?.textContent ?? '', isDir, size: null, mime: null, modified: null, permissions: null, } as unknown as WebDAVEntry; return toEntryInfo(fakeEntry, base); } catch { return null; } } export async function createFolder(path: string, _recursive = true): Promise { const manager = getWebDAVClient(); const base = manager.client.baseUrl; // The vendored client only does flat MKCOL; emulate "recursive" by // walking up and creating each missing parent first. const target = normalizePath(path); const parts = target.split('/').filter(Boolean); let built = ''; for (const part of parts) { built += '/' + part; if (await fileExists(built)) continue; const r = await manager.client.mkcol(pathToUrl(built, base)); if (!r.ok && r.status !== 405 /* already exists */) { throw new Error(`MKCOL ${built} failed: ${r.status} ${r.statusText}`); } } } export async function deletePath(path: string): Promise { const manager = getWebDAVClient(); const base = manager.client.baseUrl; const r = await manager.client.delete(pathToUrl(normalizePath(path), base)); if (!r.ok && r.status !== 204 && r.status !== 200) { throw new Error(`DELETE failed: ${r.status} ${r.statusText}`); } } export async function movePath(from: string, to: string): Promise { const manager = getWebDAVClient(); const base = manager.client.baseUrl; await manager.client.copymove( 'MOVE', pathToUrl(normalizePath(from), base), pathToUrl(normalizePath(to), base), true, ); } export async function copyPath(from: string, to: string): Promise { const manager = getWebDAVClient(); const base = manager.client.baseUrl; await manager.client.copymove( 'COPY', pathToUrl(normalizePath(from), base), pathToUrl(normalizePath(to), base), true, ); } export async function putFileContents( path: string, data: string | ArrayBuffer | Uint8Array | Blob, ): Promise { const manager = getWebDAVClient(); const base = manager.client.baseUrl; const url = pathToUrl(normalizePath(path), base); // The vendored client expects a `BodyInit`. Convert our variants to one. let body: BodyInit; if (typeof data === 'string') body = data; else if (data instanceof Blob) body = data; else if (data instanceof Uint8Array) { body = data.buffer.slice( data.byteOffset, data.byteOffset + data.byteLength, ) as ArrayBuffer; } else { body = data; } const r = await manager.client.put(url, body); if (!r.ok) { throw new Error(`PUT failed: ${r.status} ${r.statusText}`); } } export async function getFileContents(path: string): Promise { const manager = getWebDAVClient(); const base = manager.client.baseUrl; const r = await manager.client.get(pathToUrl(normalizePath(path), base)); if (!r.ok) { throw new Error(`GET failed: ${r.status} ${r.statusText}`); } return r.blob(); } /** * Fetch a byte range of a file using HTTP Range requests. * Returns a Blob containing the requested bytes. Falls back to a full * fetch if the server responds 200 (no Range support). */ export async function getFileRange(path: string, start: number, end: number): Promise { const base = getWebDAVClient().client.baseUrl; const url = pathToUrl(normalizePath(path), base); const r = await fetch(url, { headers: { Range: `bytes=${start}-${end}` } }); if (r.status === 206 || r.status === 200) return r.blob(); throw new Error(`GET range failed: ${r.status} ${r.statusText}`); } // ----- Helpers -------------------------------------------------------- /** Convert a server-relative path to an absolute URL on the configured base. */ function pathToUrl(p: string, baseUrl: string): string { // Encode each segment so spaces and special characters become valid URL // components, while preserving the '/' separators. const encoded = p.split('/').map(seg => seg ? encodeURIComponent(seg) : '').join('/'); const sep = baseUrl.endsWith('/') || encoded.startsWith('/') ? '' : '/'; return baseUrl + sep + encoded; } /** Convert a raw byte count to a short human string (e.g. "1.4 MB"). */ export function humanFileSize(bytes: number): string { if (!Number.isFinite(bytes) || bytes < 0) return '—'; if (bytes < 1024) return `${bytes} B`; const units = ['KB', 'MB', 'GB', 'TB']; let v = bytes / 1024; let i = 0; while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } return `${v.toFixed(v >= 10 ? 0 : 1)} ${units[i]}`; } export type FetchProgress = { received: number; total: number }; export async function clearDirectory(path: string): Promise { try { const entries = await listDirectory(path); await Promise.all(entries.filter(e => e.type === 'file').map(e => deletePath(e.path))); } catch { // Directory may not exist yet — ignore } } export async function streamFetch( res: Response, onProgress: (p: FetchProgress) => void, ): Promise { if (!res.body) return res.arrayBuffer(); const total = +(res.headers.get('content-length') ?? 0); const reader = res.body.getReader(); const chunks: Uint8Array[] = []; let received = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); received += value.byteLength; onProgress({ received, total }); } const out = new Uint8Array(received); let off = 0; for (const c of chunks) { out.set(c, off); off += c.byteLength; } return out.buffer; }