diff --git a/.gitignore b/.gitignore index af69cae..71a0957 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/* files/* node_modules/* package-lock.json +__pycache__/* diff --git a/package.json b/package.json index 4efb4d3..92bf5a9 100644 --- a/package.json +++ b/package.json @@ -62,8 +62,7 @@ "sonner": "2.0.3", "tailwind-merge": "3.2.0", "tw-animate-css": "1.3.8", - "vaul": "1.1.2", - "webdav": "^5.10.0" + "vaul": "1.1.2" }, "devDependencies": { "@tailwindcss/vite": "4.1.12", diff --git a/src/app/components/FileBrowser.tsx b/src/app/components/FileBrowser.tsx index 2644725..7047b70 100644 --- a/src/app/components/FileBrowser.tsx +++ b/src/app/components/FileBrowser.tsx @@ -1,5 +1,17 @@ -import { useState } from 'react'; -import { Folder, File, ChevronRight, Home } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { Folder, File, ChevronRight, Home, RefreshCw, Upload, FolderPlus, Trash2, ArrowLeft, Loader2 } from 'lucide-react'; +import { + createFolder, + deletePath, + humanFileSize, + joinPath, + listDirectory, + normalizePath, + putFileContents, + splitPath, + type EntryInfo, +} from '../webdav'; +import { toast } from 'sonner'; interface FileBrowserProps { currentPath: string; @@ -7,69 +19,50 @@ interface FileBrowserProps { onClose: () => void; } +type Mode = 'pick-file' | 'pick-folder' | 'browse'; + export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrowserProps) { - const [path, setPath] = useState(currentPath || '/'); + const [path, setPath] = useState(() => normalizePath(currentPath || '/')); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [mode, setMode] = useState('browse'); + const [showNewFolder, setShowNewFolder] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + const fileInputRef = useRef(null); - // Mock file system structure - in a real app this would come from an API - const getContents = (currentPath: string) => { - const mockFS: Record = { - '/': [ - { name: 'sd', type: 'folder' }, - { name: 'autoboot.d64', type: 'file' }, - { name: 'config.json', type: 'file' } - ], - '/sd': [ - { name: 'games', type: 'folder' }, - { name: 'demos', type: 'folder' }, - { name: 'utilities', type: 'folder' }, - { name: 'disk1.d64', type: 'file' }, - { name: 'disk2.d81', type: 'file' } - ], - '/sd/games': [ - { name: 'arcade', type: 'folder' }, - { name: 'adventure', type: 'folder' }, - { name: 'game1.d64', type: 'file' }, - { name: 'game2.d64', type: 'file' }, - { name: 'game3.prg', type: 'file' } - ], - '/sd/demos': [ - { name: 'demo1.d64', type: 'file' }, - { name: 'demo2.d64', type: 'file' } - ], - '/sd/utilities': [ - { name: 'util1.prg', type: 'file' }, - { name: 'util2.d64', type: 'file' } - ], - '/sd/games/arcade': [ - { name: 'pacman.d64', type: 'file' }, - { name: 'galaga.d64', type: 'file' } - ], - '/sd/games/adventure': [ - { name: 'zork.d64', type: 'file' }, - { name: 'adventure.d64', type: 'file' } - ] - }; - - return mockFS[currentPath] || []; + const load = async (p: string) => { + setLoading(true); + setError(null); + try { + const items = await listDirectory(p); + setEntries(items); + } catch (e: any) { + const msg = (e && e.message) || 'Failed to load directory'; + setError(msg); + setEntries([]); + } finally { + setLoading(false); + } }; - const contents = getContents(path); + useEffect(() => { + void load(path); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [path]); const navigateUp = () => { if (path === '/') return; - const parts = path.split('/').filter(Boolean); - parts.pop(); - setPath(parts.length ? '/' + parts.join('/') : '/'); + setPath(splitPath(path).parent); }; const navigateToFolder = (folderName: string) => { - const newPath = path === '/' ? `/${folderName}` : `${path}/${folderName}`; - setPath(newPath); + setPath(joinPath(path, folderName)); }; - const selectFile = (fileName: string) => { - const fullPath = path === '/' ? `/${fileName}` : `${path}/${fileName}`; - onSelect(fullPath); + const selectFile = (entry: EntryInfo) => { + if (entry.type !== 'file') return; + onSelect(entry.path); onClose(); }; @@ -78,6 +71,59 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow onClose(); }; + const refresh = () => { + void load(path); + }; + + const handleCreateFolder = async () => { + const name = newFolderName.trim(); + if (!name) return; + try { + await createFolder(joinPath(path, name), true); + setNewFolderName(''); + setShowNewFolder(false); + toast.success(`Created folder "${name}"`); + void load(path); + } catch (e: any) { + toast.error(`Failed to create folder: ${e?.message || e}`); + } + }; + + const handleDelete = async (entry: EntryInfo) => { + const ok = window.confirm( + entry.type === 'folder' + ? `Delete folder "${entry.name}" and all its contents?` + : `Delete file "${entry.name}"?`, + ); + if (!ok) return; + try { + await deletePath(entry.path); + toast.success(`Deleted ${entry.name}`); + void load(path); + } catch (e: any) { + toast.error(`Failed to delete: ${e?.message || e}`); + } + }; + + const handleUpload = async (file: File) => { + const target = joinPath(path, file.name); + try { + const buf = await file.arrayBuffer(); + await putFileContents(target, buf); + toast.success(`Uploaded ${file.name}`); + void load(path); + } catch (e: any) { + toast.error(`Failed to upload ${file.name}: ${e?.message || e}`); + } + }; + + const onPickFiles = (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files) return; + Array.from(files).forEach((f) => void handleUpload(f)); + e.target.value = ''; + }; + const pathParts = path.split('/').filter(Boolean); return ( @@ -87,15 +133,103 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow onClick={(e) => e.stopPropagation()} >
-
+

Browse Files

- +
+ + + + + + + +
+ {showNewFolder && ( +
+ setNewFolderName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void handleCreateFolder(); + if (e.key === 'Escape') { + setShowNewFolder(false); + setNewFolderName(''); + } + }} + placeholder="New folder name" + className="flex-1 px-2 py-1 text-sm border border-neutral-300 rounded" + autoFocus + /> + +
+ )} +
- {pathParts.map((part, index) => ( @@ -116,58 +250,115 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
- {path !== '/' && ( - + {loading && ( +
+ + Loading… +
)} - {contents.map((item, index) => ( - - ))} - - {contents.length === 0 && ( -
- Empty folder + {!loading && error && ( +
+
Failed to load directory
+
{error}
+
)} + + {!loading && !error && ( + <> + {path !== '/' && ( + + )} + + {entries.map((entry) => ( +
+ + +
+ ))} + + {entries.length === 0 && ( +
+ Empty folder +
+ )} + + )}
- + {mode === 'pick-folder' ? ( + + ) : mode === 'pick-file' ? ( +
+ Tap a file above to select it. ({entries.filter((e) => e.type === 'file').length} files) +
+ ) : ( +
+ {entries.filter((e) => e.type === 'folder').length} folders ·{' '} + {entries.filter((e) => e.type === 'file').length} files +
+ )}
diff --git a/src/app/components/SearchOverlay.tsx b/src/app/components/SearchOverlay.tsx index a987594..7cddd8d 100644 --- a/src/app/components/SearchOverlay.tsx +++ b/src/app/components/SearchOverlay.tsx @@ -2,6 +2,11 @@ import { useState } from 'react'; import { X, Search, HardDrive, Loader2 } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { toast } from 'sonner'; +import { + humanFileSize, + listDirectory, + type EntryInfo, +} from '../webdav'; interface SearchOverlayProps { config: any; @@ -13,7 +18,26 @@ interface SearchResult { name: string; path: string; type: string; - size: string; + size: number; + sizeText: string; +} + +const HARDWARE_FILE_EXTS = new Set([ + 'd64', 'd71', 'd81', 'd82', 'dnp', 't64', 'tap', 'prg', 'p00', 'crt', 'bin', 'g64', 'nib', +]); + +function fileExtension(p: string): string { + const dot = p.lastIndexOf('.'); + if (dot < 0) return ''; + return p.slice(dot + 1).toLowerCase(); +} + +function detectType(entry: EntryInfo): string { + if (entry.type === 'folder') return 'DIR'; + const ext = fileExtension(entry.name); + if (!ext) return 'FILE'; + if (HARDWARE_FILE_EXTS.has(ext)) return ext.toUpperCase(); + return ext.toUpperCase(); } export default function SearchOverlay({ config, setConfig, onClose }: SearchOverlayProps) { @@ -25,40 +49,78 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver const [results, setResults] = useState([]); const [hasSearched, setHasSearched] = useState(false); const [showDeviceMenu, setShowDeviceMenu] = useState(null); + const [searchError, setSearchError] = useState(null); const handleSearch = async () => { if (!query.trim()) { toast.error('Please enter a search term'); return; } - setIsSearching(true); setHasSearched(true); setResults([]); + setSearchError(null); - // Simulate search with delay - await new Promise(resolve => setTimeout(resolve, 1500)); + try { + const found: SearchResult[] = []; + const needle = query.trim().toLowerCase(); + const max = 500; // safety cap - // Mock search results - const mockResults: SearchResult[] = [ - { name: 'Pac-Man.d64', path: '/sd/games/arcade/pacman.d64', type: 'D64', size: '174 KB' }, - { name: 'Galaga.d64', path: '/sd/games/arcade/galaga.d64', type: 'D64', size: '174 KB' }, - { name: 'Adventure.d64', path: '/sd/games/adventure/adventure.d64', type: 'D64', size: '174 KB' }, - { name: 'Zork.d64', path: '/sd/games/adventure/zork.d64', type: 'D64', size: '174 KB' }, - { name: 'Demo1.d64', path: '/sd/demos/demo1.d64', type: 'D64', size: '174 KB' }, - { name: 'Utility.prg', path: '/sd/utilities/util1.prg', type: 'PRG', size: '12 KB' } - ].filter(result => - result.name.toLowerCase().includes(query.toLowerCase()) - ); + // BFS through the WebDAV tree. + const queue: string[] = ['/']; + const seen = new Set(); + while (queue.length > 0 && found.length < max) { + const dir = queue.shift()!; + if (seen.has(dir)) continue; + seen.add(dir); + let items; + try { + items = await listDirectory(dir); + } catch { + // Skip directories we cannot read (permission/404/etc.) and keep going. + continue; + } + for (const it of items) { + if (it.type === 'folder') { + queue.push(it.path); + continue; + } + if ( + it.name.toLowerCase().includes(needle) || + it.path.toLowerCase().includes(needle) + ) { + found.push({ + name: it.name, + path: it.path, + type: detectType(it), + size: it.size, + sizeText: humanFileSize(it.size), + }); + } + } + } - setResults(mockResults); - setIsSearching(false); + // Sort results: closest match by name first, then by path length, then alpha. + found.sort((a, b) => { + const an = a.name.toLowerCase(); + const bn = b.name.toLowerCase(); + const aStarts = an.startsWith(needle) ? 0 : 1; + const bStarts = bn.startsWith(needle) ? 0 : 1; + if (aStarts !== bStarts) return aStarts - bStarts; + if (a.path.length !== b.path.length) return a.path.length - b.path.length; + return an.localeCompare(bn); + }); + + setResults(found); + } catch (e: any) { + setSearchError((e && e.message) || 'Search failed'); + } finally { + setIsSearching(false); + } }; const handleMount = (deviceNum: string, result: SearchResult) => { const newConfig = JSON.parse(JSON.stringify(config)); - - // Update the device URL if (newConfig.iec?.devices?.drive?.[deviceNum]) { newConfig.iec.devices.drive[deviceNum].url = result.path; setConfig(newConfig); @@ -68,7 +130,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver }; const getAvailableDevices = () => { - const devices = []; + const devices: { number: string; name: string; url?: string }[] = []; if (config.iec?.devices?.drive) { for (const [num, device] of Object.entries(config.iec.devices.drive)) { if (num !== 'vdrive' && num !== 'rom' && (device as any).enabled) { @@ -79,6 +141,10 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver return devices; }; + // Suppress unused-var warnings for fields the UI exposes but doesn't yet + // map to a real filter (the WebDAV server doesn't carry these as metadata). + void systemType; void videoStandard; void language; + const availableDevices = getAvailableDevices(); return ( @@ -174,11 +240,17 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver {isSearching && (
-
Searching...
+
Searching…
)} - {!isSearching && hasSearched && ( + {!isSearching && searchError && ( +
+ Search failed: {searchError} +
+ )} + + {!isSearching && !searchError && hasSearched && (
{results.length > 0 ? ( <> @@ -188,7 +260,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
{results.map((result, index) => (
@@ -200,7 +272,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
{result.path}
- {result.type} • {result.size} + {result.type} · {result.sizeText}
@@ -244,7 +316,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver {!hasSearched && (
-
Enter a search term to find files
+
Enter a search term to find files on the WebDAV server
)}
diff --git a/src/app/components/StatusPage.tsx b/src/app/components/StatusPage.tsx index 3e072a8..6d03db7 100644 --- a/src/app/components/StatusPage.tsx +++ b/src/app/components/StatusPage.tsx @@ -1,9 +1,10 @@ -import { useState } from 'react'; -import { HardDrive, Activity, Wifi, Signal, Clock, RefreshCw, FolderOpen, Map } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { HardDrive, Activity, Wifi, Signal, Clock, RefreshCw, FolderOpen, Map, Loader2 } from 'lucide-react'; import DeviceDetailOverlay from './DeviceDetailOverlay'; import { ImageWithFallback } from './figma/ImageWithFallback'; import { Dialog, DialogContent, DialogTitle, DialogDescription } from './ui/dialog'; import DirectoryListing from './DirectoryListing'; +import { listDirectory, normalizePath, splitPath, type EntryInfo } from '../webdav'; interface StatusPageProps { config: any; @@ -63,6 +64,47 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) { const [showDirectory, setShowDirectory] = useState(false); const [showDiskMap, setShowDiskMap] = useState(false); + // Real directory contents for the active device's mounted file. + // Pulled from the WebDAV server (parent folder of the mounted image). + const [dirEntries, setDirEntries] = useState([]); + const [dirLoading, setDirLoading] = useState(false); + const [dirError, setDirError] = useState(null); + + const directoryPath: string | null = (() => { + const url = activeDevice?.url; + if (!url) return null; + return splitPath(normalizePath(url)).parent; + })(); + + useEffect(() => { + if (!showDirectory) return; + if (!directoryPath) { + setDirEntries([]); + setDirError(null); + return; + } + let cancelled = false; + setDirLoading(true); + setDirError(null); + listDirectory(directoryPath) + .then((items) => { + if (cancelled) return; + setDirEntries(items); + }) + .catch((e: any) => { + if (cancelled) return; + setDirError((e && e.message) || 'Failed to load directory'); + setDirEntries([]); + }) + .finally(() => { + if (cancelled) return; + setDirLoading(false); + }); + return () => { + cancelled = true; + }; + }, [showDirectory, directoryPath]); + return (
@@ -220,55 +262,36 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
- {(() => { - // Derive a directory listing for the currently mounted file. - // In a real device this would come from reading the disk's - // BAM/ directory sectors. Here we synthesize a plausible - // listing based on the mounted file's name. - const fileName = activeDevice.url ? activeDevice.url.split('/').pop() : ''; - if (!fileName) { - return ( -
- No file mounted on this device. -
- ); - } + {!activeDevice?.url && ( +
+ No file mounted on this device. +
+ )} - // Mock directory entries (C64-style: blocks, name, type) - const mockEntries = [ - { blocks: 42, name: 'PAC-MAN', type: 'PRG' }, - { blocks: 38, name: 'GALAGA', type: 'PRG' }, - { blocks: 21, name: 'HISCORE', type: 'SEQ' }, - { blocks: 12, name: 'LOADER', type: 'PRG' }, - { blocks: 5, name: 'TITLE-SCREEN', type: 'SEQ' }, - { blocks: 3, name: 'CONFIG', type: 'SEQ' }, - { blocks: 1, name: 'PARTICLES', type: 'PRG' }, - ]; + {activeDevice?.url && dirLoading && ( +
+ + Loading directory from WebDAV… +
+ )} - const totalBlocks = 664; // standard D64 capacity - const usedBlocks = mockEntries.reduce((sum, e) => sum + e.blocks, 0); - const freeBlocks = totalBlocks - usedBlocks; + {activeDevice?.url && !dirLoading && dirError && ( +
+
Failed to load directory
+
{dirError}
+
+ )} - return ( - <> -
- BLOCKS - NAME - TYPE - {usedBlocks} BLOCKS USED · {freeBlocks} FREE -
-
- -
- - ); - })()} + {activeDevice?.url && !dirLoading && !dirError && ( + ({ + name: e.name, + type: e.type === 'folder' ? 'DIR' : (e.name.split('.').pop() || 'FILE').toUpperCase(), + blocks: e.type === 'file' ? Math.max(1, Math.ceil(e.size / 254)) : 0, + }))} + footerNote={`${dirEntries.length} ENTRIES · ${directoryPath ?? ''}`} + /> + )}
diff --git a/src/app/vendor/webdav-component/esm/index.d.ts b/src/app/vendor/webdav-component/esm/index.d.ts new file mode 100644 index 0000000..27e80ca --- /dev/null +++ b/src/app/vendor/webdav-component/esm/index.d.ts @@ -0,0 +1,57 @@ +/** + * @webdav-component + * + * A modular WebDAV navigator for the browser. + * + * Three layers, each usable on its own: + * + * 1. `WebDAVClient` – low-level WebDAV protocol (PROPFIND, list, COPY, MOVE, PUT, GET, DELETE, MKCOL). + * 2. `WebDAVManager` – high-level actions built on top of the client (open, navigate, rename, + * copy, move, mkdir, upload, download, WOPI). No DOM dependency. + * 3. `WebDAVUI` – the default file-browser DOM component. Wraps a manager and renders + * the file table, toolbar, dialogs, drag/drop, etc. + * + * Quick start (with UI): + * + * ```ts + * import { WebDAVManager, WebDAVUI } from 'webdav-component'; + * import 'webdav-component/style.css'; + * + * const manager = new WebDAVManager({ + * url: 'https://example.com/remote.php/webdav/', + * username: 'me', + * password: 'secret', + * }); + * const ui = new WebDAVUI(manager); + * ui.start(); + * ``` + * + * Headless usage (no DOM): + * + * ```ts + * import { WebDAVManager } from 'webdav-component'; + * + * const manager = new WebDAVManager({ url: 'https://example.com/remote.php/webdav/' }); + * const listing = await manager.open('https://example.com/remote.php/webdav/Music/'); + * for (const entry of Object.values(listing)) { + * console.log(entry.name, entry.size, entry.modified); + * } + * ``` + * + * @packageDocumentation + */ +export { WebDAVClient, WebDAVError, PROPFIND_LIST_BODY, PROPFIND_WOPI_BODY } from './protocol/client.js'; +export { parsePropfindListing } from './protocol/parse.js'; +export { PERMISSION_CODES, hasPermission, hasPermissionOrDefault } from './protocol/permissions.js'; +export { WebDAVManager } from './operations/manager.js'; +export { WopiRegistry } from './operations/wopi.js'; +export { WebDAVUI } from './ui/component.js'; +export { TextEditor } from './ui/editor.js'; +export { buildPageTemplate, buildDialogTemplate, buildParentRow, buildDirRow, buildFileRow, buildPasteWidget, renderEntry, } from './ui/templates.js'; +export { normalizeURL, joinURL, dirname, basename, parentCollectionURL, stripHostPrefix, } from './utils/url.js'; +export { template, htmlEscape, formatBytes, formatDate, makeTranslate } from './utils/format.js'; +export type { DownloadResult, NavigationEvent, PermissionCode, Permissions, ProgressInfo, SelectionChangeEvent, SortOrder, WebDAVAuth, WebDAVClientOptions, WebDAVEntry, WebDAVListing, WebDAVManagerOptions, } from './types.js'; +export type { WebDAVUIOptions } from './ui/component.js'; +export type { WopiApp } from './operations/wopi.js'; +export type { TextEditorDeps } from './ui/editor.js'; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/src/app/vendor/webdav-component/esm/index.js b/src/app/vendor/webdav-component/esm/index.js new file mode 100644 index 0000000..e188a2b --- /dev/null +++ b/src/app/vendor/webdav-component/esm/index.js @@ -0,0 +1,53 @@ +/** + * @webdav-component + * + * A modular WebDAV navigator for the browser. + * + * Three layers, each usable on its own: + * + * 1. `WebDAVClient` – low-level WebDAV protocol (PROPFIND, list, COPY, MOVE, PUT, GET, DELETE, MKCOL). + * 2. `WebDAVManager` – high-level actions built on top of the client (open, navigate, rename, + * copy, move, mkdir, upload, download, WOPI). No DOM dependency. + * 3. `WebDAVUI` – the default file-browser DOM component. Wraps a manager and renders + * the file table, toolbar, dialogs, drag/drop, etc. + * + * Quick start (with UI): + * + * ```ts + * import { WebDAVManager, WebDAVUI } from 'webdav-component'; + * import 'webdav-component/style.css'; + * + * const manager = new WebDAVManager({ + * url: 'https://example.com/remote.php/webdav/', + * username: 'me', + * password: 'secret', + * }); + * const ui = new WebDAVUI(manager); + * ui.start(); + * ``` + * + * Headless usage (no DOM): + * + * ```ts + * import { WebDAVManager } from 'webdav-component'; + * + * const manager = new WebDAVManager({ url: 'https://example.com/remote.php/webdav/' }); + * const listing = await manager.open('https://example.com/remote.php/webdav/Music/'); + * for (const entry of Object.values(listing)) { + * console.log(entry.name, entry.size, entry.modified); + * } + * ``` + * + * @packageDocumentation + */ +export { WebDAVClient, WebDAVError, PROPFIND_LIST_BODY, PROPFIND_WOPI_BODY } from './protocol/client.js'; +export { parsePropfindListing } from './protocol/parse.js'; +export { PERMISSION_CODES, hasPermission, hasPermissionOrDefault } from './protocol/permissions.js'; +export { WebDAVManager } from './operations/manager.js'; +export { WopiRegistry } from './operations/wopi.js'; +export { WebDAVUI } from './ui/component.js'; +export { TextEditor } from './ui/editor.js'; +export { buildPageTemplate, buildDialogTemplate, buildParentRow, buildDirRow, buildFileRow, buildPasteWidget, renderEntry, } from './ui/templates.js'; +export { normalizeURL, joinURL, dirname, basename, parentCollectionURL, stripHostPrefix, } from './utils/url.js'; +export { template, htmlEscape, formatBytes, formatDate, makeTranslate } from './utils/format.js'; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/src/app/vendor/webdav-component/esm/operations/manager.d.ts b/src/app/vendor/webdav-component/esm/operations/manager.d.ts new file mode 100644 index 0000000..ff37d52 --- /dev/null +++ b/src/app/vendor/webdav-component/esm/operations/manager.d.ts @@ -0,0 +1,116 @@ +/** + * High-level WebDAV actions built on top of {@link WebDAVClient}. + * + * The manager is DOM-free: it can be used in any JavaScript environment + * (browser, Node, Web Worker, tests). It exposes: + * - the current collection URL and listing + * - a typed event emitter for navigation / selection / progress + * - the high-level actions: navigate, rename, delete, copy, move, mkdir, + * upload, download, open in WOPI + * + * The optional {@link WebDAVUI} (in `../ui/component.ts`) consumes a manager + * and renders the default file browser UI on top of it. + */ +import { WebDAVClient } from '../protocol/client.js'; +import type { DownloadResult, NavigationEvent, ProgressInfo, WebDAVEntry, WebDAVListing, WebDAVManagerOptions } from '../types.js'; +import { WopiRegistry } from './wopi.js'; +type Listener = (event: T) => void; +/** A minimal event emitter. */ +declare class Emitter { + private listeners; + on(event: string, fn: Listener): () => void; + emit(event: string, payload: T): void; +} +/** Manager events. */ +export interface ManagerEventMap { + navigation: NavigationEvent; + error: Error; + progress: ProgressInfo; +} +/** + * High-level WebDAV operations and state. + */ +export declare class WebDAVManager extends Emitter { + /** The low-level WebDAV client. */ + readonly client: WebDAVClient; + /** The currently-open collection URL. */ + url: string; + /** The currently-open collection's own entry. */ + currentEntry: WebDAVEntry | null; + /** The entries in the currently-open collection (excludes `.`). */ + files: WebDAVListing; + /** Optional WOPI registry, populated when `wopiDiscoveryUrl` is set. */ + wopi: WopiRegistry | null; + /** Currently-selected entries (e.g. via the UI's checkboxes). */ + readonly selection: Set; + /** i18n translator. */ + readonly t: (key: string) => string; + /** @internal pending navigation tokens, used to ignore stale results. */ + private navToken; + /** @internal last sort order. */ + private _sortOrder; + /** @internal last sort direction. */ + private _sortDesc; + constructor(options: WebDAVManagerOptions); + /** The current sort order. */ + get sortOrder(): 'name' | 'size' | 'date'; + set sortOrder(v: 'name' | 'size' | 'date'); + get sortDesc(): boolean; + set sortDesc(v: boolean); + /** Build a NavigationEvent for the current state (used after async init). */ + private lastNavigation; + /** Resolve a Promise that completes once the initial PROPFIND has resolved. */ + private inflight; + ready(): Promise; + /** + * Navigate to a collection URL and emit a `navigation` event. + * Returns the listing of the new collection. + * + * Note: browser-history management is the UI layer's responsibility — + * the manager is DOM-free. Subscribe to the `navigation` event and push + * `history.pushState` from your own code if you want back/forward support. + */ + open(url: string): Promise; + /** Reload the current collection. */ + reload(): Promise; + /** Construct the synthetic `currentEntry` for collections that don't have a `.` entry. */ + private fallbackEntry; + /** Find a free filename in the current directory (e.g. `foo (2).txt`). */ + getFreeFilename(filename: string): string; + /** + * Upload a file to the current directory under the given (already-encoded) + * name. Returns the new entry's URL. + */ + uploadFile(name: string, body: BodyInit, contentType?: string): Promise; + /** + * Download a file and return its blob plus a suggested filename. + * Callers are responsible for revoking the object URL when done. + */ + downloadFile(entry: WebDAVEntry, onProgress?: (info: ProgressInfo) => void): Promise; + /** + * Move a file/collection within the server (WebDAV MOVE). The destination + * is computed by replacing the source's name with `newName` in the same + * parent directory. + */ + rename(srcUrl: string, newName: string): Promise; + /** Copy or move a file/collection to the current directory. */ + paste(srcUrl: string, action: 'copy' | 'move'): Promise; + /** Delete a file or empty collection. */ + remove(url: string): Promise; + /** Create a new (empty) collection. */ + mkdir(name: string): Promise; + /** Create a new empty text file. */ + mkfile(name: string, content?: string): Promise; + /** Open a file in an external WOPI editor/viewer. */ + openInWopi(entry: WebDAVEntry, mode?: 'edit' | 'view'): Promise<{ + url: string; + token: string; + tokenTtl: number; + src: string; + }>; + /** Strongly-typed `on` shorthand for {@link ManagerEventMap}. */ + onEvent(event: K, fn: Listener): () => void; +} +/** Re-export commonly used types so consumers can `import { WebDAVManager, WebDAVClient } from 'webdav-component'`. */ +export type { WebDAVClientOptions, WebDAVEntry, WebDAVListing, WebDAVManagerOptions } from '../types.js'; +//# sourceMappingURL=manager.d.ts.map \ No newline at end of file diff --git a/src/app/vendor/webdav-component/esm/operations/manager.js b/src/app/vendor/webdav-component/esm/operations/manager.js new file mode 100644 index 0000000..d18b05a --- /dev/null +++ b/src/app/vendor/webdav-component/esm/operations/manager.js @@ -0,0 +1,304 @@ +/** + * High-level WebDAV actions built on top of {@link WebDAVClient}. + * + * The manager is DOM-free: it can be used in any JavaScript environment + * (browser, Node, Web Worker, tests). It exposes: + * - the current collection URL and listing + * - a typed event emitter for navigation / selection / progress + * - the high-level actions: navigate, rename, delete, copy, move, mkdir, + * upload, download, open in WOPI + * + * The optional {@link WebDAVUI} (in `../ui/component.ts`) consumes a manager + * and renders the default file browser UI on top of it. + */ +import { WebDAVClient, WebDAVError } from '../protocol/client.js'; +import { joinURL, basename, normalizeURL } from '../utils/url.js'; +import { WopiRegistry } from './wopi.js'; +/** A minimal event emitter. */ +class Emitter { + constructor() { + this.listeners = new Map(); + } + on(event, fn) { + let set = this.listeners.get(event); + if (!set) { + set = new Set(); + this.listeners.set(event, set); + } + set.add(fn); + return () => set.delete(fn); + } + emit(event, payload) { + const set = this.listeners.get(event); + if (!set) { + return; + } + for (const fn of set) { + try { + fn(payload); + } + catch (e) { + console.error('Listener for ' + event + ' threw:', e); + } + } + } +} +/** + * High-level WebDAV operations and state. + */ +export class WebDAVManager extends Emitter { + constructor(options) { + super(); + /** The currently-open collection's own entry. */ + this.currentEntry = null; + /** The entries in the currently-open collection (excludes `.`). */ + this.files = {}; + /** Optional WOPI registry, populated when `wopiDiscoveryUrl` is set. */ + this.wopi = null; + /** Currently-selected entries (e.g. via the UI's checkboxes). */ + this.selection = new Set(); + /** @internal pending navigation tokens, used to ignore stale results. */ + this.navToken = 0; + /** @internal last sort order. */ + this._sortOrder = 'name'; + /** @internal last sort direction. */ + this._sortDesc = false; + /** Resolve a Promise that completes once the initial PROPFIND has resolved. */ + this.inflight = null; + this.url = options.url; + this.client = new WebDAVClient(options); + this.t = (key) => (options.i18n && key in options.i18n ? options.i18n[key] : key); + if (options.wopiDiscoveryUrl) { + // WOPI discovery is async; consumers can await `ready()`. + WopiRegistry.load(options.wopiDiscoveryUrl, this.client['fetchImpl']) + .then((reg) => { this.wopi = reg; this.emit('navigation', this.lastNavigation()); }) + .catch((e) => this.emit('error', e instanceof Error ? e : new Error(String(e)))); + } + } + /** The current sort order. */ + get sortOrder() { + return this._sortOrder; + } + set sortOrder(v) { + this._sortOrder = v; + } + get sortDesc() { + return this._sortDesc; + } + set sortDesc(v) { + this._sortDesc = v; + } + /** Build a NavigationEvent for the current state (used after async init). */ + lastNavigation() { + return { + previous: null, + current: this.url, + entries: this.files, + currentEntry: this.currentEntry || this.fallbackEntry(), + }; + } + ready() { + if (this.inflight) { + return this.inflight; + } + this.inflight = this.open(this.url); + return this.inflight; + } + /** + * Navigate to a collection URL and emit a `navigation` event. + * Returns the listing of the new collection. + * + * Note: browser-history management is the UI layer's responsibility — + * the manager is DOM-free. Subscribe to the `navigation` event and push + * `history.pushState` from your own code if you want back/forward support. + */ + async open(url) { + const token = ++this.navToken; + this.url = normalizeURL(url, this.client.baseUrl); + const previous = this.url; + try { + const files = await this.client.list(this.url); + if (token !== this.navToken) { + // A newer navigation request superseded us. + return files; + } + const compareCurrent = this.url.replace(/\/+$/, ''); + const dot = files['.'] || Object.values(files).find((f) => (f.uri || f.url || '').replace(/\/+$/, '') === compareCurrent); + if (dot) { + delete files['.']; + } + this.files = files; + this.currentEntry = dot || this.fallbackEntry(); + // Clear stale selection. + this.selection.clear(); + const ev = { + previous, + current: this.url, + entries: this.files, + currentEntry: this.currentEntry, + }; + this.emit('navigation', ev); + return files; + } + catch (e) { + this.emit('error', e instanceof Error ? e : new Error(String(e))); + throw e; + } + } + /** Reload the current collection. */ + reload() { + return this.open(this.url); + } + /** Construct the synthetic `currentEntry` for collections that don't have a `.` entry. */ + fallbackEntry() { + return { + uri: this.url, + url: this.url, + path: (this.url || '').substring(this.client.baseUrl.length), + name: this.t('My files'), + size: null, + mime: null, + modified: null, + isDir: true, + permissions: null, + }; + } + /** Find a free filename in the current directory (e.g. `foo (2).txt`). */ + getFreeFilename(filename) { + const increment = (s) => s.replace(/(?:\s+\((\d+)\))?(\.[^.]+)?$/, (_m, i, ext) => { + const n = parseInt(i || '0', 10) + 1; + return ' (' + n + ')' + (ext || ''); + }); + let n = 0; + while (this.files[filename]) { + filename = increment(filename); + if (n++ > 100) { + break; + } + } + return filename; + } + /** + * Upload a file to the current directory under the given (already-encoded) + * name. Returns the new entry's URL. + */ + async uploadFile(name, body, contentType) { + const url = joinURL(this.url, name); + const r = await this.client.put(url, body, contentType); + if (!r.ok) { + throw new WebDAVError(r.status, r.statusText, await r.text().catch(() => undefined)); + } + return url; + } + /** + * Download a file and return its blob plus a suggested filename. + * Callers are responsible for revoking the object URL when done. + */ + async downloadFile(entry, onProgress) { + if (entry.isDir) { + throw new Error('Cannot download a directory: ' + entry.name); + } + // Use XHR for progress events; fall back to fetch if XHR unavailable (Node). + if (typeof XMLHttpRequest !== 'undefined') { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.responseType = 'blob'; + xhr.open('GET', entry.url); + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + const blob = xhr.response; + const url = URL.createObjectURL(blob); + resolve({ url, name: entry.name, size: blob.size, blob }); + } + else { + reject(new WebDAVError(xhr.status, xhr.statusText)); + } + }; + xhr.onerror = () => reject(new WebDAVError(xhr.status || 0, xhr.statusText || 'Network error')); + if (onProgress) { + xhr.onprogress = (ev) => onProgress({ loaded: ev.loaded, total: ev.total }); + } + xhr.send(); + }); + } + // Fallback: fetch. + const r = await this.client.get(entry.url); + if (!r.ok) { + throw new WebDAVError(r.status, r.statusText, await r.text().catch(() => undefined)); + } + const blob = await r.blob(); + return { url: URL.createObjectURL(blob), name: entry.name, size: blob.size, blob }; + } + /** + * Move a file/collection within the server (WebDAV MOVE). The destination + * is computed by replacing the source's name with `newName` in the same + * parent directory. + */ + async rename(srcUrl, newName) { + const dst = joinURL(this.url, encodeURIComponent(newName)); + const r = await this.client.copymove('MOVE', srcUrl, dst, false); + if (!r.ok) { + throw new WebDAVError(r.status, r.statusText, await r.text().catch(() => undefined)); + } + } + /** Copy or move a file/collection to the current directory. */ + async paste(srcUrl, action) { + if (action === 'move') { + // Don't paste to the same directory. + const srcDir = srcUrl.replace(/\/[^/]+\/?$/, ''); + if (srcDir.replace(/\/+$/, '') === this.url.replace(/\/+$/, '')) { + throw new Error('Cannot paste on itself'); + } + } + const name = this.getFreeFilename(basename(decodeURIComponent(srcUrl))); + const dst = this.url + name; + const method = action === 'copy' ? 'COPY' : 'MOVE'; + const r = await this.client.copymove(method, srcUrl, dst, false); + if (!r.ok) { + throw new WebDAVError(r.status, r.statusText, await r.text().catch(() => undefined)); + } + } + /** Delete a file or empty collection. */ + async remove(url) { + const r = await this.client.delete(url); + if (!r.ok) { + throw new WebDAVError(r.status, r.statusText, await r.text().catch(() => undefined)); + } + } + /** Create a new (empty) collection. */ + async mkdir(name) { + const url = joinURL(this.url, encodeURIComponent(name) + '/'); + const r = await this.client.mkcol(url); + if (!r.ok) { + throw new WebDAVError(r.status, r.statusText, await r.text().catch(() => undefined)); + } + return url; + } + /** Create a new empty text file. */ + async mkfile(name, content = '') { + return this.uploadFile(encodeURIComponent(name), content, 'text/plain'); + } + /** Open a file in an external WOPI editor/viewer. */ + async openInWopi(entry, mode = 'edit') { + if (!this.wopi) { + throw new Error('WOPI is not configured'); + } + const wopiUrl = mode === 'view' + ? this.wopi.getViewUrl(entry.name, entry.mime) + : this.wopi.getEditUrl(entry.name, entry.mime); + if (!wopiUrl) { + throw new Error('No WOPI handler for ' + entry.name); + } + const props = await this.client.propfindWopi(entry.url); + if (!props) { + throw new Error('Server did not return WOPI properties for ' + entry.name); + } + const finalUrl = wopiUrl + '&WOPISrc=' + encodeURIComponent(props.url); + return { url: finalUrl, token: props.token, tokenTtl: props.tokenTtl, src: props.url }; + } + /** Strongly-typed `on` shorthand for {@link ManagerEventMap}. */ + onEvent(event, fn) { + return this.on(event, fn); + } +} +//# sourceMappingURL=manager.js.map \ No newline at end of file diff --git a/src/app/vendor/webdav-component/esm/operations/wopi.d.ts b/src/app/vendor/webdav-component/esm/operations/wopi.d.ts new file mode 100644 index 0000000..e687735 --- /dev/null +++ b/src/app/vendor/webdav-component/esm/operations/wopi.d.ts @@ -0,0 +1,27 @@ +/** + * WOPI discovery & URL helpers. The manager holds a single WopiRegistry + * populated from a discovery XML document. + */ +export interface WopiApp { + name: string; + urlSrc: string; + ext: string | null; +} +export declare class WopiRegistry { + /** Apps keyed by MIME type. */ + private readonly byMime; + /** Apps keyed by file extension. */ + private readonly byExt; + /** + * Parse a WOPI discovery XML document and populate the registry. + * Discovery is a `` with nested `` and `` tags. + */ + static load(url: string, fetchImpl?: typeof fetch): Promise; + /** Find the edit URL for a given file (by name) and MIME type. */ + getEditUrl(name: string, mime: string | null): string | null; + /** Find the view URL for a given file (by name) and MIME type. */ + getViewUrl(name: string, mime: string | null): string | null; + /** True if the registry knows about any file types. */ + get hasApps(): boolean; +} +//# sourceMappingURL=wopi.d.ts.map \ No newline at end of file diff --git a/src/app/vendor/webdav-component/esm/operations/wopi.js b/src/app/vendor/webdav-component/esm/operations/wopi.js new file mode 100644 index 0000000..159a432 --- /dev/null +++ b/src/app/vendor/webdav-component/esm/operations/wopi.js @@ -0,0 +1,78 @@ +export class WopiRegistry { + constructor() { + /** Apps keyed by MIME type. */ + this.byMime = {}; + /** Apps keyed by file extension. */ + this.byExt = {}; + } + /** + * Parse a WOPI discovery XML document and populate the registry. + * Discovery is a `` with nested `` and `` tags. + */ + static async load(url, fetchImpl = fetch.bind(globalThis)) { + const r = await fetchImpl(url, { method: 'GET' }); + if (!r.ok) { + throw new Error('Failed to fetch WOPI discovery: ' + r.status + ' ' + r.statusText); + } + const text = await r.text(); + const xml = new DOMParser().parseFromString(text, 'text/xml'); + const reg = new WopiRegistry(); + xml.querySelectorAll('app').forEach((app) => { + const name = app.getAttribute('name') || ''; + const mimeMatch = name.match(/^.*\/.*$/); + const mime = mimeMatch ? mimeMatch[0] : null; + app.querySelectorAll('action').forEach((action) => { + const ext = (action.getAttribute('ext') || '').toUpperCase(); + const urlSrc = (action.getAttribute('urlsrc') || '').replace(/<[^>]*&>/g, ''); + const actionName = action.getAttribute('name') || ''; + const entry = { name: actionName, urlSrc, ext: ext || null }; + if (mime) { + (reg.byMime[mime] = reg.byMime[mime] || []).push(entry); + } + else if (ext) { + (reg.byExt[ext] = reg.byExt[ext] || []).push(entry); + } + }); + }); + return reg; + } + /** Find the edit URL for a given file (by name) and MIME type. */ + getEditUrl(name, mime) { + if (mime && this.byMime[mime]) { + const edit = this.byMime[mime].find((a) => a.name === 'edit'); + if (edit) { + return edit.urlSrc; + } + } + const ext = name.replace(/^.*\.(\w+)$/, '$1').toUpperCase(); + if (this.byExt[ext]) { + const edit = this.byExt[ext].find((a) => a.name === 'edit'); + if (edit) { + return edit.urlSrc; + } + } + return null; + } + /** Find the view URL for a given file (by name) and MIME type. */ + getViewUrl(name, mime) { + if (mime && this.byMime[mime]) { + const view = this.byMime[mime].find((a) => a.name === 'view'); + if (view) { + return view.urlSrc; + } + } + const ext = name.replace(/^.*\.(\w+)$/, '$1').toUpperCase(); + if (this.byExt[ext]) { + const view = this.byExt[ext].find((a) => a.name === 'view'); + if (view) { + return view.urlSrc; + } + } + return this.getEditUrl(name, mime); + } + /** True if the registry knows about any file types. */ + get hasApps() { + return Object.keys(this.byExt).length > 0 || Object.keys(this.byMime).length > 0; + } +} +//# sourceMappingURL=wopi.js.map \ No newline at end of file diff --git a/src/app/vendor/webdav-component/esm/protocol/client.d.ts b/src/app/vendor/webdav-component/esm/protocol/client.d.ts new file mode 100644 index 0000000..f676a84 --- /dev/null +++ b/src/app/vendor/webdav-component/esm/protocol/client.d.ts @@ -0,0 +1,64 @@ +/** + * The WebDAV protocol client. Low-level; no UI or high-level actions. + * + * All methods are async and return Promises. Errors are surfaced by rejecting + * the Promise (or, for non-fatal "not found" cases, returning a falsy value). + */ +import type { WebDAVClientOptions, WebDAVListing, WebDAVAuth } from '../types.js'; +/** The PROPFIND body used to list a directory's contents. */ +export declare const PROPFIND_LIST_BODY: string; +/** The PROPFIND body used to fetch WOPI properties for a single file. */ +export declare const PROPFIND_WOPI_BODY: string; +export declare class WebDAVError extends Error { + readonly status: number; + readonly statusText: string; + readonly detail?: string; + constructor(status: number, statusText: string, detail?: string); +} +export declare class WebDAVClient { + /** The base URL of the WebDAV server. */ + readonly baseUrl: string; + /** Default headers (typically just Authorization). */ + readonly defaultHeaders: Record; + /** The fetch implementation to use. */ + private readonly fetchImpl; + constructor(options: WebDAVClientOptions); + /** The current auth (re-derived from default headers). */ + get auth(): WebDAVAuth; + /** Send a raw WebDAV/HTTP request. */ + send(method: string, url: string, body?: BodyInit | null, headers?: Record): Promise; + /** + * Issue a PROPFIND with a custom body. Returns the parsed XML document. + */ + propfind(url: string, body: string, depth?: number, extraHeaders?: Record): Promise; + /** + * List the contents of a collection. Returns a map of entries keyed by + * name (with `.` representing the collection itself). + */ + list(url: string): Promise; + /** + * Fetch WOPI properties (URL, token, ttl) for a single file. + * Returns `null` if the server doesn't support WOPI. + */ + propfindWopi(url: string): Promise<{ + url: string; + token: string; + tokenTtl: number; + } | null>; + /** Upload a file (PUT). */ + put(url: string, body: BodyInit, contentType?: string): Promise; + /** Download a file (GET) and return the response. */ + get(url: string): Promise; + /** Delete a file or empty collection. */ + delete(url: string): Promise; + /** Create a new collection (MKCOL). */ + mkcol(url: string): Promise; + /** + * Copy or move a file. `method` must be `'COPY'` or `'MOVE'`. + * `dst` is resolved against the base URL. + */ + copymove(method: 'COPY' | 'MOVE', src: string, dst: string, overwrite?: boolean): Promise; + /** HEAD request — returns true if the resource exists. */ + exists(url: string): Promise; +} +//# sourceMappingURL=client.d.ts.map \ No newline at end of file diff --git a/src/app/vendor/webdav-component/esm/protocol/client.js b/src/app/vendor/webdav-component/esm/protocol/client.js new file mode 100644 index 0000000..b493a11 --- /dev/null +++ b/src/app/vendor/webdav-component/esm/protocol/client.js @@ -0,0 +1,138 @@ +import { normalizeURL } from '../utils/url.js'; +import { sendRequest } from '../utils/fetch.js'; +import { parsePropfindListing } from './parse.js'; +/** The PROPFIND body used to list a directory's contents. */ +export const PROPFIND_LIST_BODY = '' + + '' + + '' + + '' + + '' + + '' + + ''; +/** The PROPFIND body used to fetch WOPI properties for a single file. */ +export const PROPFIND_WOPI_BODY = '' + + '' + + '' + + '' + + '' + + ''; +export class WebDAVError extends Error { + constructor(status, statusText, detail) { + super(status + ' ' + statusText + (detail ? '\n' + detail : '')); + this.name = 'WebDAVError'; + this.status = status; + this.statusText = statusText; + this.detail = detail; + } +} +export class WebDAVClient { + constructor(options) { + this.baseUrl = options.url; + this.fetchImpl = options.fetchImpl || (typeof fetch !== 'undefined' ? fetch.bind(globalThis) : (() => { + throw new Error('No fetch implementation available. Pass one via WebDAVClientOptions.fetchImpl.'); + })()); + this.defaultHeaders = { ...(options.headers || {}) }; + if (options.username && options.password) { + this.defaultHeaders['Authorization'] = 'Basic ' + btoa(options.username + ':' + options.password); + } + } + /** The current auth (re-derived from default headers). */ + get auth() { + const a = this.defaultHeaders['Authorization'] || ''; + if (!a.startsWith('Basic ')) { + return {}; + } + try { + const decoded = atob(a.slice('Basic '.length)); + const idx = decoded.indexOf(':'); + if (idx === -1) { + return {}; + } + return { username: decoded.slice(0, idx), password: decoded.slice(idx + 1) }; + } + catch { + return {}; + } + } + /** Send a raw WebDAV/HTTP request. */ + send(method, url, body, headers) { + return sendRequest(this.fetchImpl, { method, url, body, headers }, this.defaultHeaders); + } + /** + * Issue a PROPFIND with a custom body. Returns the parsed XML document. + */ + async propfind(url, body, depth = 1, extraHeaders) { + const headers = { + 'Depth': String(depth), + 'Content-Type': 'text/xml; charset=utf-8', + ...(extraHeaders || {}), + }; + const response = await this.send('PROPFIND', url, body, headers); + const text = await response.text(); + return new DOMParser().parseFromString(text, 'text/xml'); + } + /** + * List the contents of a collection. Returns a map of entries keyed by + * name (with `.` representing the collection itself). + */ + async list(url) { + const normalized = normalizeURL(url, this.baseUrl); + const xml = await this.propfind(normalized, PROPFIND_LIST_BODY, 1); + return parsePropfindListing(xml, { + url: normalized, + baseUrl: this.baseUrl, + auth: this.auth, + }); + } + /** + * Fetch WOPI properties (URL, token, ttl) for a single file. + * Returns `null` if the server doesn't support WOPI. + */ + async propfindWopi(url) { + const normalized = normalizeURL(url, this.baseUrl); + const xml = await this.propfind(normalized, PROPFIND_WOPI_BODY, 0); + const src = xml.querySelector('wopi-url')?.textContent || null; + const token = xml.querySelector('token')?.textContent || null; + const ttl = xml.querySelector('token-ttl')?.textContent || null; + if (!src || !token) { + return null; + } + return { + url: src, + token, + tokenTtl: ttl ? +ttl : Date.now() + 3600 * 1000, + }; + } + /** Upload a file (PUT). */ + put(url, body, contentType) { + return this.send('PUT', normalizeURL(url, this.baseUrl), body, contentType ? { 'Content-Type': contentType } : undefined); + } + /** Download a file (GET) and return the response. */ + get(url) { + return this.send('GET', normalizeURL(url, this.baseUrl)); + } + /** Delete a file or empty collection. */ + delete(url) { + return this.send('DELETE', normalizeURL(url, this.baseUrl)); + } + /** Create a new collection (MKCOL). */ + mkcol(url) { + return this.send('MKCOL', normalizeURL(url, this.baseUrl)); + } + /** + * Copy or move a file. `method` must be `'COPY'` or `'MOVE'`. + * `dst` is resolved against the base URL. + */ + copymove(method, src, dst, overwrite = false) { + return this.send(method, src, '', { + 'Destination': normalizeURL(dst, this.baseUrl), + 'Overwrite': overwrite ? 'T' : 'F', + }); + } + /** HEAD request — returns true if the resource exists. */ + async exists(url) { + const r = await this.send('HEAD', normalizeURL(url, this.baseUrl)); + return r.status === 200; + } +} +//# sourceMappingURL=client.js.map \ No newline at end of file diff --git a/src/app/vendor/webdav-component/esm/protocol/parse.d.ts b/src/app/vendor/webdav-component/esm/protocol/parse.d.ts new file mode 100644 index 0000000..2cf68ea --- /dev/null +++ b/src/app/vendor/webdav-component/esm/protocol/parse.d.ts @@ -0,0 +1,19 @@ +/** + * XML response parsing for PROPFIND. Pure functions over a parsed DOM. + */ +import type { WebDAVListing, WebDAVAuth } from '../types.js'; +export interface ParseContext { + /** The URL that was PROPFIND'd, normalized. */ + url: string; + /** The base URL of the WebDAV server (used to compute `path`). */ + baseUrl: string; + /** Optional auth for the host. Used by `stripHostPrefix`. */ + auth: WebDAVAuth; +} +/** + * Parse a PROPFIND `multistatus` XML document into a listing keyed by name. + * The entry whose URI matches the requested URL is keyed as `.` (the current + * collection). + */ +export declare function parsePropfindListing(xml: Document, ctx: ParseContext): WebDAVListing; +//# sourceMappingURL=parse.d.ts.map \ No newline at end of file diff --git a/src/app/vendor/webdav-component/esm/protocol/parse.js b/src/app/vendor/webdav-component/esm/protocol/parse.js new file mode 100644 index 0000000..17400f8 --- /dev/null +++ b/src/app/vendor/webdav-component/esm/protocol/parse.js @@ -0,0 +1,76 @@ +import { stripHostPrefix } from '../utils/url.js'; +/** + * Parse a PROPFIND `multistatus` XML document into a listing keyed by name. + * The entry whose URI matches the requested URL is keyed as `.` (the current + * collection). + */ +export function parsePropfindListing(xml, ctx) { + const files = {}; + const compareUrl = ctx.url.replace(/\/+$/, ''); + xml.querySelectorAll('response').forEach((node) => { + const href = node.querySelector('href'); + if (!href || !href.textContent) { + return; + } + const itemUri = href.textContent; + const compareItemUri = itemUri.replace(/\/+$/, ''); + // Find the first 200 propstat; ignore 404s etc. + let propsNode = null; + node.querySelectorAll('propstat').forEach((propstat) => { + if (propsNode) { + return; + } + const status = propstat.querySelector('status'); + if (status && /200/.test(status.textContent || '')) { + propsNode = propstat; + } + }); + if (!propsNode) { + console.error('Cannot find properties for: ' + itemUri); + return; + } + // Alias for narrow-friendly access below. + const props = propsNode; + // Displayname falls back to the last path segment. + let name = itemUri.replace(/\/$/, '').split('/').pop() || ''; + try { + name = decodeURIComponent(name); + } + catch { + // leave name as-is on malformed encoding + } + const displayname = props.querySelector('displayname'); + if (displayname && displayname.textContent) { + name = displayname.textContent; + } + // Some servers (e.g. lighttpd) prefix each name with the hostname. + let host = null; + try { + host = new URL(ctx.baseUrl).hostname; + } + catch { + host = null; + } + name = stripHostPrefix(name, host); + const isDir = !!node.querySelector('resourcetype collection'); + const lengthEl = props.querySelector('getcontentlength'); + const mimeEl = props.querySelector('getcontenttype'); + const modifiedEl = props.querySelector('getlastmodified'); + const permsEl = props.querySelector('permissions'); + const entry = { + uri: itemUri, + url: itemUri, + path: itemUri.substring(ctx.baseUrl.length), + name, + size: !isDir && lengthEl?.textContent ? parseInt(lengthEl.textContent, 10) : null, + mime: !isDir && mimeEl?.textContent ? mimeEl.textContent : null, + modified: modifiedEl?.textContent ? new Date(modifiedEl.textContent) : null, + isDir, + permissions: permsEl?.textContent ? permsEl.textContent : null, + }; + const key = compareItemUri === compareUrl ? '.' : name; + files[key] = entry; + }); + return files; +} +//# sourceMappingURL=parse.js.map \ No newline at end of file diff --git a/src/app/vendor/webdav-component/esm/protocol/permissions.d.ts b/src/app/vendor/webdav-component/esm/protocol/permissions.d.ts new file mode 100644 index 0000000..54ab708 --- /dev/null +++ b/src/app/vendor/webdav-component/esm/protocol/permissions.d.ts @@ -0,0 +1,18 @@ +/** + * OwnCloud/Nextcloud `oc:permissions` codes. + * + * These are returned by the server as a string of single-character codes. + * See: https://doc.owncloud.com/desktop/next/appendices/architecture.html#server-side-permissions + */ +import type { PermissionCode, Permissions } from '../types.js'; +/** All known permission codes. */ +export declare const PERMISSION_CODES: readonly PermissionCode[]; +/** Test whether a permission string grants a specific code. */ +export declare function hasPermission(perms: Permissions | null, code: PermissionCode): boolean; +/** + * Test whether a permission string grants a code, falling back to a default + * when the string is missing. Used by the UI to assume "full access" when the + * server doesn't return `oc:permissions` at all. + */ +export declare function hasPermissionOrDefault(perms: Permissions | null, code: PermissionCode, fallback: boolean): boolean; +//# sourceMappingURL=permissions.d.ts.map \ No newline at end of file diff --git a/src/app/vendor/webdav-component/esm/protocol/permissions.js b/src/app/vendor/webdav-component/esm/protocol/permissions.js new file mode 100644 index 0000000..2067232 --- /dev/null +++ b/src/app/vendor/webdav-component/esm/protocol/permissions.js @@ -0,0 +1,23 @@ +/** All known permission codes. */ +export const PERMISSION_CODES = [ + 'S', 'R', 'M', 'W', 'C', 'K', 'D', 'N', 'V', +]; +/** Test whether a permission string grants a specific code. */ +export function hasPermission(perms, code) { + if (!perms) { + return false; + } + return perms.indexOf(code) !== -1; +} +/** + * Test whether a permission string grants a code, falling back to a default + * when the string is missing. Used by the UI to assume "full access" when the + * server doesn't return `oc:permissions` at all. + */ +export function hasPermissionOrDefault(perms, code, fallback) { + if (perms === null) { + return fallback; + } + return perms.indexOf(code) !== -1; +} +//# sourceMappingURL=permissions.js.map \ No newline at end of file diff --git a/src/app/vendor/webdav-component/esm/types.d.ts b/src/app/vendor/webdav-component/esm/types.d.ts new file mode 100644 index 0000000..78895f7 --- /dev/null +++ b/src/app/vendor/webdav-component/esm/types.d.ts @@ -0,0 +1,103 @@ +/** + * Public types for the WebDAV component. + * + * The package is split into three layers: + * 1. {@link WebDAVClient} – the raw WebDAV protocol client. + * 2. {@link WebDAVManager} – high-level actions built on top of the client + * (rename, copy, upload, download, etc.). + * 3. {@link WebDAVUI} – an optional browser-DOM component that uses + * a manager and renders the default UI. + * + * Headless usage (no DOM): construct a `WebDAVManager`. + * Default UI: construct a `WebDAVUI` which composes a manager. + */ +/** OwnCloud/Nextcloud `oc:permissions` codes. See {@link Permissions}. */ +export type PermissionCode = 'S' | 'R' | 'M' | 'W' | 'C' | 'K' | 'D' | 'N' | 'V'; +/** A list of permission codes returned by `oc:permissions`. */ +export type Permissions = string; +/** Auth credentials for the WebDAV server. */ +export interface WebDAVAuth { + username?: string; + password?: string; +} +/** Options for {@link WebDAVClient}. */ +export interface WebDAVClientOptions extends WebDAVAuth { + /** Base URL of the WebDAV server, e.g. `https://example.com/remote.php/webdav/`. */ + url: string; + /** Extra request headers to send on every request. */ + headers?: Record; + /** Optional fetch override (e.g. for tests, or polyfilled `fetch`). */ + fetchImpl?: typeof fetch; +} +/** Options for {@link WebDAVManager}. */ +export interface WebDAVManagerOptions extends WebDAVClientOptions { + /** + * Optional WOPI discovery URL. When set, the manager will resolve + * `wopiEditUrl` / `wopiViewUrl` for supported file types. + */ + wopiDiscoveryUrl?: string | null; + /** + * When true, render Nextcloud-style image thumbnails (only relevant for the + * UI layer; the manager itself is DOM-free). + */ + ncThumbnails?: boolean; + /** Locale strings. Missing keys fall back to the key itself. */ + i18n?: Record; +} +/** A file or directory as returned by PROPFIND. */ +export interface WebDAVEntry { + /** The full URL to this entry, including host and (for collections) trailing slash. */ + uri: string; + /** Alias for {@link uri}. */ + url: string; + /** The path relative to the manager's `baseUrl`. */ + path: string; + /** The display name. */ + name: string; + /** Size in bytes, or `null` for collections. */ + size: number | null; + /** MIME type, or `null` for collections. */ + mime: string | null; + /** Last-modified time, or `null` if unknown. */ + modified: Date | null; + /** True if this entry is a directory/collection. */ + isDir: boolean; + /** OwnCloud/Nextcloud `oc:permissions` string, or `null` if not provided. */ + permissions: Permissions | null; +} +/** A directory listing keyed by entry name (with `.` for the current collection). */ +export type WebDAVListing = Record; +/** Sort order for the UI file table. */ +export type SortOrder = 'name' | 'size' | 'date'; +/** Progress event for downloads and uploads. */ +export interface ProgressInfo { + loaded: number; + total: number; +} +/** A custom event payload for selection changes in the UI. */ +export interface SelectionChangeEvent { + selected: WebDAVEntry[]; +} +/** A custom event payload emitted by {@link WebDAVManager} when the current directory changes. */ +export interface NavigationEvent { + /** The previous collection URL, or `null` if this is the first navigation. */ + previous: string | null; + /** The new collection URL. */ + current: string; + /** The entries in the new collection (`.` is removed; it's available as `currentEntry`). */ + entries: WebDAVListing; + /** The current collection's own entry. */ + currentEntry: WebDAVEntry; +} +/** A result that callers can await. */ +export interface DownloadResult { + /** The download URL (an object URL; revoke with `URL.revokeObjectURL`). */ + url: string; + /** The suggested filename. */ + name: string; + /** The downloaded size in bytes. */ + size: number; + /** The underlying blob. */ + blob: Blob; +} +//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/src/app/vendor/webdav-component/esm/types.js b/src/app/vendor/webdav-component/esm/types.js new file mode 100644 index 0000000..9de7de8 --- /dev/null +++ b/src/app/vendor/webdav-component/esm/types.js @@ -0,0 +1,15 @@ +/** + * Public types for the WebDAV component. + * + * The package is split into three layers: + * 1. {@link WebDAVClient} – the raw WebDAV protocol client. + * 2. {@link WebDAVManager} – high-level actions built on top of the client + * (rename, copy, upload, download, etc.). + * 3. {@link WebDAVUI} – an optional browser-DOM component that uses + * a manager and renders the default UI. + * + * Headless usage (no DOM): construct a `WebDAVManager`. + * Default UI: construct a `WebDAVUI` which composes a manager. + */ +export {}; +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/src/app/vendor/webdav-component/esm/ui/component.d.ts b/src/app/vendor/webdav-component/esm/ui/component.d.ts new file mode 100644 index 0000000..57d780e --- /dev/null +++ b/src/app/vendor/webdav-component/esm/ui/component.d.ts @@ -0,0 +1,103 @@ +/** + * The default WebDAV browser UI. Wraps a {@link WebDAVManager} and renders a + * classic file table on top of it. The component owns its own DOM; call + * `mount(target)` to attach it. + * + * The component is a normal DOM element: you can style it via + * `webdav-component/style.css`, override its templates, or render it inside + * any framework by reading its DOM. + */ +import type { WebDAVManager } from '../index.js'; +/** Options for {@link WebDAVUI}. */ +export interface WebDAVUIOptions { + /** Element to mount the UI into. Defaults to the document's `
`. */ + target?: HTMLElement; + /** Whether to render Nextcloud-style image thumbnails. */ + ncThumbnails?: boolean; + /** Optional markdown converter. Defaults to a tiny built-in. */ + markdownToHTML?: (text: string) => string; + /** Locale strings. Missing keys fall back to the key itself. */ + i18n?: Record; + /** When true, do NOT bind keyboard / drag-and-drop / paste handlers. */ + quiet?: boolean; +} +/** The default UI. */ +export declare class WebDAVUI { + /** The manager this UI is bound to. */ + readonly manager: WebDAVManager; + /** The root DOM element of the UI. */ + readonly root: HTMLElement; + /** The toolbar element. */ + private toolbar; + /** The table body. */ + private tbody; + /** All currently-rendered rows. */ + private rows; + /** Dialog elements. */ + private dialogTemplate; + /** Settings. */ + private readonly options; + /** Current paste state. */ + private paste; + /** Escape-key handler for dialogs. */ + private escapeHandler; + /** Currently in-flight XHR (for abort-on-dialog-close). */ + private currentXhr; + /** Unbind functions. */ + private unbindings; + constructor(manager: WebDAVManager, options?: WebDAVUIOptions); + /** Destroy the UI: remove event listeners and clear the DOM. */ + destroy(): void; + /** Open the initial directory. */ + start(initialUrl?: string): void; + private bindToolbar; + private toggleMenu; + private bindTableHeader; + private bindDocumentDrop; + private bindGlobalShortcuts; + private onNavigation; + private applyRootPermissions; + private renderTable; + private bindParentRow; + private bindFileRow; + private applyRowPermissions; + private findEntryForRow; + private refreshSelection; + private uploadFiles; + private downloadEntry; + private downloadSelected; + private startPaste; + private cancelPaste; + private applyPaste; + private confirmDelete; + private deleteSelected; + private promptRename; + private promptMkdir; + private promptMktext; + private promptWopiNew; + private dispatchWopiOpen; + private isPreviewable; + private isEditableText; + private wopiEditUrl; + private editFile; + private editTextFile; + private openPreview; + /** + * Open a modal dialog. + * - `openDialog(html)` → simple dialog, OK button, no submit handler. + * - `openDialog(html, false)` → no OK button (preview/info dialog). + * - `openDialog(html, submit)` → OK button + submit handler. + * - `openDialog(html, false, submit)` → no OK button + submit handler. + * + * The submit handler receives a map of form values and may return `false` + * to abort, or a Promise (the dialog stays open until the promise resolves). + */ + private openDialog; + private closeDialog; + private setLoading; + private on; + private show; + private hide; + private toggle; +} +//# sourceMappingURL=component.d.ts.map \ No newline at end of file diff --git a/src/app/vendor/webdav-component/esm/ui/component.js b/src/app/vendor/webdav-component/esm/ui/component.js new file mode 100644 index 0000000..995c4b3 --- /dev/null +++ b/src/app/vendor/webdav-component/esm/ui/component.js @@ -0,0 +1,761 @@ +import { hasPermission, hasPermissionOrDefault } from '../protocol/permissions.js'; +import { joinURL, normalizeURL, parentCollectionURL } from '../utils/url.js'; +import { formatBytes, makeTranslate } from '../utils/format.js'; +import { buildDialogTemplate, buildPageTemplate, buildParentRow, buildPasteWidget, renderEntry, } from './templates.js'; +import { TextEditor } from './editor.js'; +/** The default UI. */ +export class WebDAVUI { + constructor(manager, options = {}) { + /** All currently-rendered rows. */ + this.rows = []; + /** Current paste state. */ + this.paste = null; + /** Escape-key handler for dialogs. */ + this.escapeHandler = null; + /** Currently in-flight XHR (for abort-on-dialog-close). */ + this.currentXhr = null; + /** Unbind functions. */ + this.unbindings = []; + this.manager = manager; + this.options = { + ncThumbnails: options.ncThumbnails ?? false, + quiet: options.quiet ?? false, + markdownToHTML: options.markdownToHTML ?? ((s) => identityMarkdown(s)), + }; + const t = makeTranslate(options.i18n); + const rootUrl = manager.client.baseUrl.replace(/(? table > tbody') || this.root.querySelector('table tbody'); + this.dialogTemplate = buildDialogTemplate(t); + this.manager.onEvent('navigation', (ev) => this.onNavigation(ev)); + this.manager.onEvent('error', (e) => { + console.error(e); + alert(e.message); + }); + this.bindToolbar(); + this.bindTableHeader(); + this.bindDocumentDrop(); + if (!this.options.quiet) { + this.bindGlobalShortcuts(); + } + } + /** Destroy the UI: remove event listeners and clear the DOM. */ + destroy() { + for (const u of this.unbindings) { + u(); + } + this.unbindings = []; + this.root.innerHTML = ''; + } + /** Open the initial directory. */ + start(initialUrl = this.manager.url) { + this.manager.open(initialUrl); + } + // -- toolbar ---------------------------------------------------------- + bindToolbar() { + this.on(this.toolbar, 'click', '.download', () => this.downloadSelected()); + this.on(this.toolbar, 'click', '.copy', () => this.startPaste('copy')); + this.on(this.toolbar, 'click', '.cut', () => this.startPaste('move')); + this.on(this.toolbar, 'click', '.delete', () => this.deleteSelected()); + // Hide create actions when no permissions. + this.hide('.toolbar .create, .toolbar .copy, .toolbar .cut, .toolbar .delete, .toolbar .menu'); + const menu = this.toolbar.querySelector('.menu'); + if (menu) { + menu.dataset.visible = '0'; + } + this.on(this.toolbar, 'click', '.mk', () => { + if (!menu) + return; + menu.dataset.visible = menu.dataset.visible === '0' ? '1' : '0'; + menu.style.display = menu.dataset.visible === '1' ? 'flex' : 'none'; + }); + this.on(this.toolbar, 'click', '.mkdir', () => this.promptMkdir()); + this.on(this.toolbar, 'click', '.mktext', () => this.promptMktext()); + this.on(this.toolbar, 'click', '.upload', () => { + const fi = this.toolbar.querySelector('input[type=file]'); + fi?.click(); + }); + const fileInput = this.toolbar.querySelector('input[type=file]'); + if (fileInput) { + fileInput.onchange = () => { + if (!fileInput.files || !fileInput.files.length) + return; + this.uploadFiles(Array.from(fileInput.files)); + fileInput.value = ''; + }; + } + // WOPI new-document menu. + if (this.manager.wopi?.hasApps) { + this.show('.toolbar .menu .wopi'); + this.on(this.toolbar, 'click', '.wopi input', (_ev, btn) => { + this.toggleMenu(menu, false); + this.promptWopiNew(btn.className.substr(-3).toLowerCase()); + }); + } + } + toggleMenu(menu, show) { + if (!menu) + return; + menu.dataset.visible = show ? '1' : '0'; + menu.style.display = show ? 'flex' : 'none'; + } + bindTableHeader() { + // Sort header buttons. + this.root.querySelectorAll('thead td[data-sort] button').forEach((btn) => { + btn.addEventListener('click', (e) => { + const target = e.currentTarget; + const newSort = target.parentElement.dataset.sort; + if (this.manager.sortOrder === newSort) { + this.manager.sortDesc = !this.manager.sortDesc; + } + else { + this.manager.sortOrder = newSort; + } + try { + localStorage.setItem('sort_order', this.manager.sortOrder); + localStorage.setItem('sort_order_desc', this.manager.sortDesc ? '1' : '0'); + } + catch { /* ignore */ } + this.renderTable(); + }); + }); + // Header "check all" checkbox. + const head = this.root.querySelector('thead td.check input'); + if (head) { + head.addEventListener('change', (e) => { + const checked = e.currentTarget.checked; + this.root.querySelectorAll('tbody td.check input').forEach((i) => { + i.checked = checked; + }); + this.refreshSelection(); + }); + } + } + bindDocumentDrop() { + if (this.options.quiet) + return; + let counter = 0; + const onDragOver = (e) => { e.preventDefault(); e.stopPropagation(); }; + const onDragEnter = (e) => { + e.preventDefault(); + e.stopPropagation(); + if (!counter) + document.body.classList.add('dragging'); + counter++; + }; + const onDragLeave = (e) => { + e.preventDefault(); + e.stopPropagation(); + counter--; + if (!counter) + document.body.classList.remove('dragging'); + }; + const onDrop = (e) => { + e.preventDefault(); + e.stopPropagation(); + document.body.classList.remove('dragging'); + counter = 0; + if (!e.dataTransfer) + return; + const files = Array.from(e.dataTransfer.items) + .map((it) => it.getAsFile()) + .filter((f) => f !== null); + if (files.length) + this.uploadFiles(files); + }; + window.addEventListener('dragover', onDragOver); + window.addEventListener('dragenter', onDragEnter); + window.addEventListener('dragleave', onDragLeave); + window.addEventListener('drop', onDrop); + this.unbindings.push(() => { + window.removeEventListener('dragover', onDragOver); + window.removeEventListener('dragenter', onDragEnter); + window.removeEventListener('dragleave', onDragLeave); + window.removeEventListener('drop', onDrop); + }); + } + bindGlobalShortcuts() { + const onPaste = (e) => { + if (!e.clipboardData) + return; + const IMAGE_MIME = /^image\/(p?jpeg|gif|png)$/i; + for (let i = 0; i < e.clipboardData.items.length; i++) { + const item = e.clipboardData.items[i]; + if (item.kind === 'file' || IMAGE_MIME.test(item.type)) { + e.preventDefault(); + const f = item.getAsFile(); + if (!f) + return; + const defaultName = f.name === 'image.png' + ? f.name.replace(/\./, '-' + (+(new Date())) + '.') + : f.name; + this.openDialog(`

Upload this file?

`, (vals) => { + const name = encodeURIComponent(vals.paste_name || defaultName); + this.manager.uploadFile(name, f).then(() => this.manager.reload()); + return true; + }); + const input = this.root.querySelector('input[name=paste_name]'); + if (input) { + input.value = defaultName; + input.focus(); + input.selectionStart = 0; + input.selectionEnd = defaultName.lastIndexOf('.'); + } + return; + } + } + }; + window.addEventListener('paste', onPaste); + this.unbindings.push(() => window.removeEventListener('paste', onPaste)); + } + // -- navigation events ---------------------------------------------- + onNavigation(ev) { + // Sync URL bar. + try { + history.pushState(1, '', ev.current); + } + catch { + // Cross-origin: ignore. + } + document.title = ev.currentEntry.name; + this.renderTable(); + this.applyRootPermissions(ev.currentEntry.permissions); + window.addEventListener('popstate', () => { + this.manager.open(location.pathname); + }, { once: true }); + } + applyRootPermissions(perms) { + const canCreate = !perms || hasPermission(perms, 'C') || hasPermission(perms, 'K'); + this.toggle('.toolbar .create', canCreate); + } + // -- table rendering ------------------------------------------------ + renderTable() { + const entries = Object.values(this.manager.files); + const order = this.manager.sortOrder; + const desc = this.manager.sortDesc; + // Sort. + entries.sort((a, b) => { + if (order === 'date') + return a.modified.getTime() - b.modified.getTime(); + if (order === 'size') + return (a.size || 0) - (b.size || 0); + return a.name.localeCompare(b.name); + }); + if (order !== 'date') { + entries.sort((a, b) => Number(b.isDir) - Number(a.isDir)); + } + if (desc) + entries.reverse(); + // Build HTML. + const t = (k) => this.manager.t(k); + const tpl = buildParentRow(t); + const ctx = { + t, + ncThumbnails: this.options.ncThumbnails, + rootUrl: this.manager.client.baseUrl.replace(/(? { + if (e && e.preventDefault) + e.preventDefault(); + const current = (this.manager.currentEntry && (this.manager.currentEntry.uri || this.manager.currentEntry.url)) || this.manager.url; + const parent = parentCollectionURL(current || ''); + if (parent) { + this.manager.open(parent); + } + return false; + }; + } + bindFileRow(tr) { + const entry = this.findEntryForRow(tr); + if (!entry) + return; + this.rows.push({ el: tr, entry }); + const a = tr.querySelector('th a'); + const check = tr.querySelector('td.check input[type=checkbox]'); + if (check) { + check.addEventListener('change', () => this.refreshSelection()); + } + if (entry.isDir) { + if (a) + a.onclick = (e) => { e.preventDefault(); this.manager.open(entry.url); return false; }; + } + else { + // Action buttons. + this.on(tr, 'click', '.rename', () => this.promptRename(entry)); + this.on(tr, 'click', '.delete', () => this.confirmDelete([entry.url])); + this.on(tr, 'click', '.edit', () => this.editFile(entry)); + this.on(tr, 'click', '.download', (e) => { + e.preventDefault(); + this.downloadEntry(entry); + return false; + }); + if (a) { + a.onclick = (e) => { + e.preventDefault(); + // Decide: preview, edit, or download. + if (this.isPreviewable(entry)) { + this.openPreview(entry); + } + else if (this.isEditableText(entry) || this.wopiEditUrl(entry)) { + this.editFile(entry); + } + else { + this.downloadEntry(entry); + } + return false; + }; + } + } + this.applyRowPermissions(tr, entry); + } + applyRowPermissions(tr, entry) { + const perms = entry.permissions || 'WCKDNV'; + const hide = (cls) => { + const b = tr.querySelector('.buttons .' + cls); + if (b) + b.style.display = 'none'; + }; + if (!hasPermissionOrDefault(perms, 'N', true)) + hide('rename'); + if (!hasPermissionOrDefault(perms, 'D', true)) + hide('delete'); + if (entry.isDir || !hasPermissionOrDefault(perms, 'W', true)) + hide('edit'); + if (!hasPermissionOrDefault(perms, 'V', true)) + hide('rename'); + } + findEntryForRow(tr) { + const input = tr.querySelector('td.check input[type=checkbox]'); + const url = input ? input.value : tr.querySelector('a')?.href; + if (!url) + return null; + const norm = normalizeURL(url, this.manager.client.baseUrl); + const key = norm.replace(/\/+$/, ''); + return Object.values(this.manager.files).find((f) => (f.uri || f.url || '').replace(/\/+$/, '') === key) || null; + } + refreshSelection() { + this.manager.selection.clear(); + this.root.querySelectorAll('tbody td.check input:checked').forEach((i) => { + this.manager.selection.add(i.value); + }); + const hasSel = this.manager.selection.size > 0; + this.toggle('.toolbar .selected input', hasSel); + } + // -- actions -------------------------------------------------------- + async uploadFiles(files) { + this.setLoading(true); + try { + for (const f of files) { + await this.manager.uploadFile(encodeURIComponent(f.name), f); + } + } + finally { + this.setLoading(false); + await this.manager.reload(); + } + } + async downloadEntry(entry) { + this.openDialog(`

+

${escapeHtml(entry.name)}

+ +

/ ${formatBytes(entry.size || 0)}

`, false); + try { + const result = await this.manager.downloadFile(entry, (info) => { + const p = this.root.querySelector('progress'); + if (p && info.total) + p.value = info.loaded; + const pb = this.root.querySelector('.progress_bytes'); + if (pb) + pb.innerHTML = formatBytes(info.loaded); + }); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = result.url; + a.download = result.name; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(result.url); + } + catch (e) { + console.error(e); + alert(e.message); + } + finally { + this.closeDialog(); + } + } + async downloadSelected() { + for (const url of this.manager.selection) { + const entry = Object.values(this.manager.files).find((f) => f.uri === url); + if (entry && !entry.isDir) { + await this.downloadEntry(entry); + } + } + } + startPaste(action) { + if (this.manager.selection.size === 0) { + alert(this.manager.t('No file is selected')); + return; + } + this.paste = { entries: Array.from(this.manager.selection), action }; + this.root.querySelector('.toolbar .paste').innerHTML = buildPasteWidget(this.manager.t, this.paste.entries.length, action); + this.show('.toolbar .paste'); + this.on(this.root, 'click', '.toolbar .paste .cancel', () => this.cancelPaste()); + this.on(this.root, 'click', `.toolbar .paste .${action}`, () => this.applyPaste()); + } + cancelPaste() { + this.paste = null; + this.hide('.toolbar .paste'); + } + async applyPaste() { + if (!this.paste) + return; + this.setLoading(true); + try { + for (const src of this.paste.entries) { + await this.manager.paste(src, this.paste.action); + } + } + catch (e) { + console.error(e); + alert(e.message); + } + finally { + this.cancelPaste(); + this.setLoading(false); + await this.manager.reload(); + } + } + confirmDelete(urls) { + if (!urls.length) + return; + this.openDialog(`

${this.manager.t('Confirm delete?')}

`, async () => { + this.setLoading(true); + try { + for (const u of urls) { + await this.manager.remove(u); + } + } + catch (e) { + console.error(e); + alert(e.message); + } + finally { + this.setLoading(false); + await this.manager.reload(); + } + }); + } + deleteSelected() { + if (this.manager.selection.size === 0) { + alert(this.manager.t('No file is selected')); + return; + } + this.confirmDelete(Array.from(this.manager.selection)); + } + promptRename(entry) { + this.openDialog(``, (vals) => { + if (!vals.rename) + return false; + return this.manager.rename(entry.url, vals.rename).then(() => this.manager.reload()); + }); + const input = this.root.querySelector('input[name=rename]'); + if (input) { + input.value = entry.name; + input.focus(); + input.selectionStart = 0; + input.selectionEnd = entry.name.lastIndexOf('.'); + } + } + promptMkdir() { + this.openDialog(``, (vals) => { + if (!vals.mkdir) + return false; + return this.manager.mkdir(vals.mkdir).then(() => this.manager.reload()); + }); + } + promptMktext() { + this.openDialog(``, (vals) => { + if (!vals.mkfile) + return false; + return this.manager.mkfile(vals.mkfile, '').then(() => this.manager.reload()); + }); + const input = this.root.querySelector('input[name=mkfile]'); + if (input) { + input.value = '.md'; + input.focus(); + input.selectionStart = input.selectionEnd = 0; + } + } + promptWopiNew(ext) { + this.openDialog(``, async (vals) => { + if (!vals.mkfile) + return false; + const name = encodeURIComponent(vals.mkfile + '.' + ext); + const fileUrl = joinURL(this.manager.url, name); + try { + // The legacy `webdav.js` bundled tiny base64 templates for new + // ODT/ODS/ODP/ODG files. The new component does not ship those + // payloads by default; consumers can hook in here by listening + // for the `wopi-open` event and providing the file content. + await this.manager.client.put(fileUrl, '', 'application/octet-stream'); + if (this.manager.wopi) { + const editUrl = this.manager.wopi.getEditUrl(name, null); + if (editUrl) { + this.dispatchWopiOpen(fileUrl, editUrl); + } + } + } + catch (e) { + console.error(e); + } + return true; + }); + } + dispatchWopiOpen(fileUrl, editUrl) { + this.root.dispatchEvent(new CustomEvent('wopi-open', { detail: { fileUrl, editUrl } })); + } + isPreviewable(entry) { + if (!entry.mime && !entry.name) + return false; + if ((entry.mime === 'application/pdf' || entry.name.endsWith('.pdf')) && + /Mobi|Tablet|Android|iPad|iPhone/.test(navigator.userAgent)) { + return false; + } + return /^image\//.test(entry.mime || '') || /^audio\//.test(entry.mime || '') || + /^video\//.test(entry.mime || '') || /^application\/pdf/.test(entry.mime || '') || + /^text\//.test(entry.mime || '') || entry.mime === 'application/x-empty'; + } + isEditableText(entry) { + if (/^text\/|application\/x-empty/.test(entry.mime || '')) + return true; + return /\.(md|txt|json|xml|ini|url|html?|css|js)$/i.test(entry.name); + } + wopiEditUrl(entry) { + return this.manager.wopi?.getEditUrl(entry.name, entry.mime) || null; + } + editFile(entry) { + if (this.wopiEditUrl(entry)) { + this.dispatchWopiOpen(entry.url, this.wopiEditUrl(entry)); + return; + } + if (this.isEditableText(entry)) { + this.editTextFile(entry); + return; + } + this.downloadEntry(entry); + } + async editTextFile(entry) { + const r = await this.manager.client.get(entry.url); + if (!r.ok) { + alert(this.manager.t('Cannot open file: ') + r.statusText); + return; + } + const content = await r.text(); + const isMd = /\.md$/i.test(entry.name); + this.openDialog(isMd + ? `
` + : ``, false); + const dlg = this.root.querySelector('dialog'); + dlg.classList.add('editor-dialog'); + if (isMd) + dlg.classList.add('editor-dialog-md'); + const form = dlg.querySelector('form'); + form.insertAdjacentHTML('afterbegin', `
+ + + +
`); + const txt = dlg.querySelector('textarea[name=edit]'); + txt.value = content; + const editor = new TextEditor({ + file: { url: entry.url, name: entry.name }, + translate: this.manager.t, + request: (_m, u, b) => this.manager.client.put(u, b || '', 'text/plain'), + markdownToHTML: this.options.markdownToHTML, + onClose: () => this.closeDialog(), + }); + editor.bind(txt, dlg.querySelector('.save'), dlg.querySelector('.close input'), dlg.querySelector('.autosave')); + if (isMd) { + const previewEl = dlg.querySelector('.md_preview'); + editor.bindPreview(txt, previewEl); + } + } + openPreview(entry) { + if (/\.md$/i.test(entry.name)) { + this.openDialog('
', false); + const dlg = this.root.querySelector('dialog'); + dlg.className = 'preview'; + this.manager.client.get(entry.url).then((r) => r.text()).then((t) => { + const preview = this.root.querySelector('.md_preview'); + preview.innerHTML = this.options.markdownToHTML(t); + }); + return; + } + const type = entry.mime || ''; + let html; + if (/^image\//.test(type)) + html = ``; + else if (/^audio\//.test(type)) + html = `