feat: add WebDAV integration and utility functions

- Introduced a new `webdav-component` package for WebDAV protocol handling.
- Implemented `webdav.ts` with functions for managing WebDAV operations such as listing directories, checking file existence, creating folders, deleting paths, moving files, and handling file uploads/downloads.
- Added path normalization and utility functions for handling WebDAV paths.
- Enhanced the `Tag` class in `webdav3.py` to correctly register XML namespaces, improving compatibility with WebDAV responses.
This commit is contained in:
Jaime Idolpx 2026-06-07 17:48:37 -04:00
parent 79d92dc89d
commit 4a2f6032d2
35 changed files with 4231 additions and 179 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@ dist/*
files/* files/*
node_modules/* node_modules/*
package-lock.json package-lock.json
__pycache__/*

View File

@ -62,8 +62,7 @@
"sonner": "2.0.3", "sonner": "2.0.3",
"tailwind-merge": "3.2.0", "tailwind-merge": "3.2.0",
"tw-animate-css": "1.3.8", "tw-animate-css": "1.3.8",
"vaul": "1.1.2", "vaul": "1.1.2"
"webdav": "^5.10.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "4.1.12", "@tailwindcss/vite": "4.1.12",

View File

@ -1,5 +1,17 @@
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Folder, File, ChevronRight, Home } from 'lucide-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 { interface FileBrowserProps {
currentPath: string; currentPath: string;
@ -7,69 +19,50 @@ interface FileBrowserProps {
onClose: () => void; onClose: () => void;
} }
type Mode = 'pick-file' | 'pick-folder' | 'browse';
export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrowserProps) { export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrowserProps) {
const [path, setPath] = useState(currentPath || '/'); const [path, setPath] = useState(() => normalizePath(currentPath || '/'));
const [entries, setEntries] = useState<EntryInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [mode, setMode] = useState<Mode>('browse');
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
// Mock file system structure - in a real app this would come from an API const load = async (p: string) => {
const getContents = (currentPath: string) => { setLoading(true);
const mockFS: Record<string, any[]> = { setError(null);
'/': [ try {
{ name: 'sd', type: 'folder' }, const items = await listDirectory(p);
{ name: 'autoboot.d64', type: 'file' }, setEntries(items);
{ name: 'config.json', type: 'file' } } catch (e: any) {
], const msg = (e && e.message) || 'Failed to load directory';
'/sd': [ setError(msg);
{ name: 'games', type: 'folder' }, setEntries([]);
{ name: 'demos', type: 'folder' }, } finally {
{ name: 'utilities', type: 'folder' }, setLoading(false);
{ 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 contents = getContents(path); useEffect(() => {
void load(path);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [path]);
const navigateUp = () => { const navigateUp = () => {
if (path === '/') return; if (path === '/') return;
const parts = path.split('/').filter(Boolean); setPath(splitPath(path).parent);
parts.pop();
setPath(parts.length ? '/' + parts.join('/') : '/');
}; };
const navigateToFolder = (folderName: string) => { const navigateToFolder = (folderName: string) => {
const newPath = path === '/' ? `/${folderName}` : `${path}/${folderName}`; setPath(joinPath(path, folderName));
setPath(newPath);
}; };
const selectFile = (fileName: string) => { const selectFile = (entry: EntryInfo) => {
const fullPath = path === '/' ? `/${fileName}` : `${path}/${fileName}`; if (entry.type !== 'file') return;
onSelect(fullPath); onSelect(entry.path);
onClose(); onClose();
}; };
@ -78,6 +71,59 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
onClose(); 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<HTMLInputElement>) => {
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); const pathParts = path.split('/').filter(Boolean);
return ( return (
@ -87,15 +133,103 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="sticky top-0 bg-white border-b border-neutral-200 p-4"> <div className="sticky top-0 bg-white border-b border-neutral-200 p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3 gap-2">
<h3 className="font-medium">Browse Files</h3> <h3 className="font-medium">Browse Files</h3>
<button onClick={onClose} className="text-sm text-blue-600"> <div className="flex items-center gap-1">
Cancel <button
</button> onClick={() => void refresh()}
className="p-2 rounded hover:bg-neutral-100"
aria-label="Refresh"
title="Refresh"
>
<RefreshCw className="w-4 h-4" />
</button>
<button
onClick={() => {
setMode((m) => (m === 'pick-file' ? 'browse' : 'pick-file'));
}}
className={`px-2 py-1 text-xs rounded border ${
mode === 'pick-file'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-neutral-700 border-neutral-300'
}`}
title="When on, tapping a file picks it"
>
Pick file
</button>
<button
onClick={() => {
setMode((m) => (m === 'pick-folder' ? 'browse' : 'pick-folder'));
}}
className={`px-2 py-1 text-xs rounded border ${
mode === 'pick-folder'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-neutral-700 border-neutral-300'
}`}
title="When on, the bottom action picks the current folder"
>
Pick folder
</button>
<button
onClick={() => setShowNewFolder((s) => !s)}
className="p-2 rounded hover:bg-neutral-100"
aria-label="New folder"
title="New folder"
>
<FolderPlus className="w-4 h-4" />
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="p-2 rounded hover:bg-neutral-100"
aria-label="Upload"
title="Upload file"
>
<Upload className="w-4 h-4" />
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={onPickFiles}
/>
<button onClick={onClose} className="text-sm text-blue-600 ml-1">
Cancel
</button>
</div>
</div> </div>
{showNewFolder && (
<div className="mb-3 flex items-center gap-2">
<input
value={newFolderName}
onChange={(e) => 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
/>
<button
onClick={() => void handleCreateFolder()}
className="px-2 py-1 text-sm bg-blue-600 text-white rounded"
>
Create
</button>
</div>
)}
<div className="flex items-center gap-2 text-sm text-neutral-600 overflow-x-auto"> <div className="flex items-center gap-2 text-sm text-neutral-600 overflow-x-auto">
<button onClick={() => setPath('/')} className="flex-shrink-0"> <button
onClick={() => setPath('/')}
className="flex-shrink-0 p-1 rounded hover:bg-neutral-100"
title="Root"
>
<Home className="w-4 h-4" /> <Home className="w-4 h-4" />
</button> </button>
{pathParts.map((part, index) => ( {pathParts.map((part, index) => (
@ -116,58 +250,115 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
</div> </div>
<div className="overflow-y-auto flex-1"> <div className="overflow-y-auto flex-1">
{path !== '/' && ( {loading && (
<button <div className="p-8 text-center text-neutral-500 text-sm flex flex-col items-center gap-2">
onClick={navigateUp} <Loader2 className="w-6 h-6 animate-spin" />
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-neutral-50 border-b border-neutral-100" Loading
> </div>
<div className="text-neutral-400">
<Folder className="w-5 h-5" />
</div>
<span className="text-neutral-600">..</span>
</button>
)} )}
{contents.map((item, index) => ( {!loading && error && (
<button <div className="p-4 text-sm">
key={index} <div className="text-red-600 mb-2">Failed to load directory</div>
onClick={() => { <div className="text-neutral-500 text-xs break-all">{error}</div>
if (item.type === 'folder') { <button
navigateToFolder(item.name); onClick={() => void load(path)}
} else { className="mt-3 inline-flex items-center gap-1 text-blue-600 text-sm"
selectFile(item.name); >
} <RefreshCw className="w-3 h-3" /> Retry
}} </button>
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-neutral-50 border-b border-neutral-100"
>
<div className={item.type === 'folder' ? 'text-blue-600' : 'text-neutral-400'}>
{item.type === 'folder' ? (
<Folder className="w-5 h-5" />
) : (
<File className="w-5 h-5" />
)}
</div>
<span className="text-neutral-900">{item.name}</span>
{item.type === 'folder' && (
<ChevronRight className="w-4 h-4 ml-auto text-neutral-400" />
)}
</button>
))}
{contents.length === 0 && (
<div className="p-8 text-center text-neutral-500 text-sm">
Empty folder
</div> </div>
)} )}
{!loading && !error && (
<>
{path !== '/' && (
<button
onClick={navigateUp}
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-neutral-50 border-b border-neutral-100 text-left"
>
<div className="text-neutral-400">
<ArrowLeft className="w-5 h-5" />
</div>
<span className="text-neutral-600">..</span>
</button>
)}
{entries.map((entry) => (
<div
key={entry.path}
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-neutral-50 border-b border-neutral-100"
>
<button
onClick={() => {
if (entry.type === 'folder') {
navigateToFolder(entry.name);
} else if (mode === 'pick-file') {
selectFile(entry);
}
}}
className="flex-1 flex items-center gap-3 text-left min-w-0"
>
<div className={entry.type === 'folder' ? 'text-blue-600' : 'text-neutral-400'}>
{entry.type === 'folder' ? (
<Folder className="w-5 h-5" />
) : (
<File className="w-5 h-5" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-neutral-900 truncate">{entry.name}</div>
{entry.type === 'file' && (
<div className="text-xs text-neutral-500 truncate">
{humanFileSize(entry.size)}
{entry.lastModified
? ` · ${entry.lastModified.toLocaleDateString()}`
: ''}
</div>
)}
</div>
{entry.type === 'folder' && (
<ChevronRight className="w-4 h-4 text-neutral-400" />
)}
</button>
<button
onClick={() => void handleDelete(entry)}
className="p-2 rounded hover:bg-red-50 text-red-600"
aria-label={`Delete ${entry.name}`}
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
{entries.length === 0 && (
<div className="p-8 text-center text-neutral-500 text-sm">
Empty folder
</div>
)}
</>
)}
</div> </div>
<div className="sticky bottom-0 bg-white border-t border-neutral-200 p-4"> <div className="sticky bottom-0 bg-white border-t border-neutral-200 p-4">
<button {mode === 'pick-folder' ? (
onClick={selectCurrentFolder} <button
className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg" onClick={selectCurrentFolder}
> className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg"
Select Folder: {path} >
</button> Select Folder: {path}
</button>
) : mode === 'pick-file' ? (
<div className="text-xs text-neutral-500 text-center">
Tap a file above to select it. ({entries.filter((e) => e.type === 'file').length} files)
</div>
) : (
<div className="text-xs text-neutral-500 text-center">
{entries.filter((e) => e.type === 'folder').length} folders ·{' '}
{entries.filter((e) => e.type === 'file').length} files
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,6 +2,11 @@ import { useState } from 'react';
import { X, Search, HardDrive, Loader2 } from 'lucide-react'; import { X, Search, HardDrive, Loader2 } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import {
humanFileSize,
listDirectory,
type EntryInfo,
} from '../webdav';
interface SearchOverlayProps { interface SearchOverlayProps {
config: any; config: any;
@ -13,7 +18,26 @@ interface SearchResult {
name: string; name: string;
path: string; path: string;
type: 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) { export default function SearchOverlay({ config, setConfig, onClose }: SearchOverlayProps) {
@ -25,40 +49,78 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
const [results, setResults] = useState<SearchResult[]>([]); const [results, setResults] = useState<SearchResult[]>([]);
const [hasSearched, setHasSearched] = useState(false); const [hasSearched, setHasSearched] = useState(false);
const [showDeviceMenu, setShowDeviceMenu] = useState<number | null>(null); const [showDeviceMenu, setShowDeviceMenu] = useState<number | null>(null);
const [searchError, setSearchError] = useState<string | null>(null);
const handleSearch = async () => { const handleSearch = async () => {
if (!query.trim()) { if (!query.trim()) {
toast.error('Please enter a search term'); toast.error('Please enter a search term');
return; return;
} }
setIsSearching(true); setIsSearching(true);
setHasSearched(true); setHasSearched(true);
setResults([]); setResults([]);
setSearchError(null);
// Simulate search with delay try {
await new Promise(resolve => setTimeout(resolve, 1500)); const found: SearchResult[] = [];
const needle = query.trim().toLowerCase();
const max = 500; // safety cap
// Mock search results // BFS through the WebDAV tree.
const mockResults: SearchResult[] = [ const queue: string[] = ['/'];
{ name: 'Pac-Man.d64', path: '/sd/games/arcade/pacman.d64', type: 'D64', size: '174 KB' }, const seen = new Set<string>();
{ name: 'Galaga.d64', path: '/sd/games/arcade/galaga.d64', type: 'D64', size: '174 KB' }, while (queue.length > 0 && found.length < max) {
{ name: 'Adventure.d64', path: '/sd/games/adventure/adventure.d64', type: 'D64', size: '174 KB' }, const dir = queue.shift()!;
{ name: 'Zork.d64', path: '/sd/games/adventure/zork.d64', type: 'D64', size: '174 KB' }, if (seen.has(dir)) continue;
{ name: 'Demo1.d64', path: '/sd/demos/demo1.d64', type: 'D64', size: '174 KB' }, seen.add(dir);
{ name: 'Utility.prg', path: '/sd/utilities/util1.prg', type: 'PRG', size: '12 KB' } let items;
].filter(result => try {
result.name.toLowerCase().includes(query.toLowerCase()) 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); // Sort results: closest match by name first, then by path length, then alpha.
setIsSearching(false); 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 handleMount = (deviceNum: string, result: SearchResult) => {
const newConfig = JSON.parse(JSON.stringify(config)); const newConfig = JSON.parse(JSON.stringify(config));
// Update the device URL
if (newConfig.iec?.devices?.drive?.[deviceNum]) { if (newConfig.iec?.devices?.drive?.[deviceNum]) {
newConfig.iec.devices.drive[deviceNum].url = result.path; newConfig.iec.devices.drive[deviceNum].url = result.path;
setConfig(newConfig); setConfig(newConfig);
@ -68,7 +130,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
}; };
const getAvailableDevices = () => { const getAvailableDevices = () => {
const devices = []; const devices: { number: string; name: string; url?: string }[] = [];
if (config.iec?.devices?.drive) { if (config.iec?.devices?.drive) {
for (const [num, device] of Object.entries(config.iec.devices.drive)) { for (const [num, device] of Object.entries(config.iec.devices.drive)) {
if (num !== 'vdrive' && num !== 'rom' && (device as any).enabled) { if (num !== 'vdrive' && num !== 'rom' && (device as any).enabled) {
@ -79,6 +141,10 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
return devices; 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(); const availableDevices = getAvailableDevices();
return ( return (
@ -174,11 +240,17 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
{isSearching && ( {isSearching && (
<div className="flex flex-col items-center justify-center py-12"> <div className="flex flex-col items-center justify-center py-12">
<Loader2 className="w-12 h-12 text-blue-600 animate-spin mb-4" /> <Loader2 className="w-12 h-12 text-blue-600 animate-spin mb-4" />
<div className="text-neutral-600">Searching...</div> <div className="text-neutral-600">Searching</div>
</div> </div>
)} )}
{!isSearching && hasSearched && ( {!isSearching && searchError && (
<div className="text-center py-12 text-red-600">
Search failed: {searchError}
</div>
)}
{!isSearching && !searchError && hasSearched && (
<div className="max-h-96 overflow-y-auto"> <div className="max-h-96 overflow-y-auto">
{results.length > 0 ? ( {results.length > 0 ? (
<> <>
@ -188,7 +260,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
<div className="space-y-2"> <div className="space-y-2">
{results.map((result, index) => ( {results.map((result, index) => (
<div <div
key={index} key={result.path}
className="bg-neutral-50 border border-neutral-200 rounded-lg p-4 flex items-center justify-between hover:bg-neutral-100" className="bg-neutral-50 border border-neutral-200 rounded-lg p-4 flex items-center justify-between hover:bg-neutral-100"
> >
<div className="flex items-center gap-3 flex-1 min-w-0"> <div className="flex items-center gap-3 flex-1 min-w-0">
@ -200,7 +272,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
<div className="text-sm text-neutral-500 truncate">{result.path}</div> <div className="text-sm text-neutral-500 truncate">{result.path}</div>
</div> </div>
<div className="text-xs text-neutral-500 flex-shrink-0"> <div className="text-xs text-neutral-500 flex-shrink-0">
{result.type} {result.size} {result.type} · {result.sizeText}
</div> </div>
</div> </div>
<div className="relative ml-3"> <div className="relative ml-3">
@ -244,7 +316,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
{!hasSearched && ( {!hasSearched && (
<div className="text-center py-12 text-neutral-400"> <div className="text-center py-12 text-neutral-400">
<Search className="w-12 h-12 mx-auto mb-3 opacity-50" /> <Search className="w-12 h-12 mx-auto mb-3 opacity-50" />
<div>Enter a search term to find files</div> <div>Enter a search term to find files on the WebDAV server</div>
</div> </div>
)} )}
</div> </div>

View File

@ -1,9 +1,10 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { HardDrive, Activity, Wifi, Signal, Clock, RefreshCw, FolderOpen, Map } from 'lucide-react'; import { HardDrive, Activity, Wifi, Signal, Clock, RefreshCw, FolderOpen, Map, Loader2 } from 'lucide-react';
import DeviceDetailOverlay from './DeviceDetailOverlay'; import DeviceDetailOverlay from './DeviceDetailOverlay';
import { ImageWithFallback } from './figma/ImageWithFallback'; import { ImageWithFallback } from './figma/ImageWithFallback';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from './ui/dialog'; import { Dialog, DialogContent, DialogTitle, DialogDescription } from './ui/dialog';
import DirectoryListing from './DirectoryListing'; import DirectoryListing from './DirectoryListing';
import { listDirectory, normalizePath, splitPath, type EntryInfo } from '../webdav';
interface StatusPageProps { interface StatusPageProps {
config: any; config: any;
@ -63,6 +64,47 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
const [showDirectory, setShowDirectory] = useState(false); const [showDirectory, setShowDirectory] = useState(false);
const [showDiskMap, setShowDiskMap] = 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<EntryInfo[]>([]);
const [dirLoading, setDirLoading] = useState(false);
const [dirError, setDirError] = useState<string | null>(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 ( return (
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
@ -220,55 +262,36 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
<button onClick={() => setShowDirectory(false)} className="p-2 -m-2 hover:bg-neutral-100 rounded-lg"><svg xmlns='http://www.w3.org/2000/svg' className='w-6 h-6' fill='none' viewBox='0 0 24 24' stroke='currentColor'><path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' /></svg></button> <button onClick={() => setShowDirectory(false)} className="p-2 -m-2 hover:bg-neutral-100 rounded-lg"><svg xmlns='http://www.w3.org/2000/svg' className='w-6 h-6' fill='none' viewBox='0 0 24 24' stroke='currentColor'><path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' /></svg></button>
</div> </div>
<div className="flex-1 overflow-auto flex flex-col"> <div className="flex-1 overflow-auto flex flex-col">
{(() => { {!activeDevice?.url && (
// Derive a directory listing for the currently mounted file. <div className="p-8 text-center text-neutral-500 text-sm">
// In a real device this would come from reading the disk's No file mounted on this device.
// BAM/ directory sectors. Here we synthesize a plausible </div>
// listing based on the mounted file's name. )}
const fileName = activeDevice.url ? activeDevice.url.split('/').pop() : '';
if (!fileName) {
return (
<div className="p-8 text-center text-neutral-500 text-sm">
No file mounted on this device.
</div>
);
}
// Mock directory entries (C64-style: blocks, name, type) {activeDevice?.url && dirLoading && (
const mockEntries = [ <div className="p-8 text-center text-neutral-500 text-sm flex flex-col items-center gap-2">
{ blocks: 42, name: 'PAC-MAN', type: 'PRG' }, <Loader2 className="w-6 h-6 animate-spin" />
{ blocks: 38, name: 'GALAGA', type: 'PRG' }, Loading directory from WebDAV
{ blocks: 21, name: 'HISCORE', type: 'SEQ' }, </div>
{ 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' },
];
const totalBlocks = 664; // standard D64 capacity {activeDevice?.url && !dirLoading && dirError && (
const usedBlocks = mockEntries.reduce((sum, e) => sum + e.blocks, 0); <div className="p-4 text-sm">
const freeBlocks = totalBlocks - usedBlocks; <div className="text-red-600 mb-2">Failed to load directory</div>
<div className="text-neutral-500 text-xs break-all">{dirError}</div>
</div>
)}
return ( {activeDevice?.url && !dirLoading && !dirError && (
<> <DirectoryListing
<div entries={dirEntries.map((e) => ({
className="px-4 py-2 bg-neutral-100 text-xs text-neutral-600 border-b border-neutral-200 flex items-center" name: e.name,
style={{ fontFamily: "'C64_Pro_Mono', monospace" }} type: e.type === 'folder' ? 'DIR' : (e.name.split('.').pop() || 'FILE').toUpperCase(),
> blocks: e.type === 'file' ? Math.max(1, Math.ceil(e.size / 254)) : 0,
<span className="inline-block w-16">BLOCKS</span> }))}
<span className="inline-block w-40">NAME</span> footerNote={`${dirEntries.length} ENTRIES · ${directoryPath ?? ''}`}
<span className="inline-block w-16">TYPE</span> />
<span className="ml-auto">{usedBlocks} BLOCKS USED · {freeBlocks} FREE</span> )}
</div>
<div className="flex-1 overflow-auto">
<DirectoryListing
entries={mockEntries}
footerNote={`${mockEntries.length} FILES · ${fileName}`}
/>
</div>
</>
);
})()}
</div> </div>
</div> </div>
</div> </div>

View File

@ -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

View File

@ -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

View File

@ -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<T> = (event: T) => void;
/** A minimal event emitter. */
declare class Emitter {
private listeners;
on<T>(event: string, fn: Listener<T>): () => void;
emit<T>(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<string>;
/** 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<WebDAVListing>;
/**
* 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<WebDAVListing>;
/** Reload the current collection. */
reload(): Promise<WebDAVListing>;
/** 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<string>;
/**
* 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<DownloadResult>;
/**
* 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<void>;
/** Copy or move a file/collection to the current directory. */
paste(srcUrl: string, action: 'copy' | 'move'): Promise<void>;
/** Delete a file or empty collection. */
remove(url: string): Promise<void>;
/** Create a new (empty) collection. */
mkdir(name: string): Promise<string>;
/** Create a new empty text file. */
mkfile(name: string, content?: string): Promise<string>;
/** 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<K extends keyof ManagerEventMap>(event: K, fn: Listener<ManagerEventMap[K]>): () => 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

View File

@ -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

View File

@ -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 `<wopi-discovery>` with nested `<app>` and `<action>` tags.
*/
static load(url: string, fetchImpl?: typeof fetch): Promise<WopiRegistry>;
/** 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

View File

@ -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 `<wopi-discovery>` with nested `<app>` and `<action>` 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

View File

@ -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<string, string>;
/** 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<string, string>): Promise<Response>;
/**
* Issue a PROPFIND with a custom body. Returns the parsed XML document.
*/
propfind(url: string, body: string, depth?: number, extraHeaders?: Record<string, string>): Promise<Document>;
/**
* List the contents of a collection. Returns a map of entries keyed by
* name (with `.` representing the collection itself).
*/
list(url: string): Promise<WebDAVListing>;
/**
* 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<Response>;
/** Download a file (GET) and return the response. */
get(url: string): Promise<Response>;
/** Delete a file or empty collection. */
delete(url: string): Promise<Response>;
/** Create a new collection (MKCOL). */
mkcol(url: string): Promise<Response>;
/**
* 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<Response>;
/** HEAD request — returns true if the resource exists. */
exists(url: string): Promise<boolean>;
}
//# sourceMappingURL=client.d.ts.map

View File

@ -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 = '<?xml version="1.0" encoding="UTF-8"?>' +
'<D:propfind xmlns:D="DAV:" xmlns:oc="http://owncloud.org/ns">' +
'<D:prop>' +
'<D:getlastmodified/><D:getcontenttype/><D:getcontentlength/>' +
'<D:resourcetype/><D:displayname/><oc:permissions/>' +
'</D:prop>' +
'</D:propfind>';
/** The PROPFIND body used to fetch WOPI properties for a single file. */
export const PROPFIND_WOPI_BODY = '<?xml version="1.0" encoding="UTF-8"?>' +
'<D:propfind xmlns:D="DAV:" xmlns:W="https://interoperability.blob.core.windows.net/files/MS-WOPI/">' +
'<D:prop>' +
'<W:wopi-url/><W:token/><W:token-ttl/>' +
'</D:prop>' +
'</D:propfind>';
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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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<string, string>;
/** 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<string, string>;
}
/** 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<string, WebDAVEntry>;
/** 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

View File

@ -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

View File

@ -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 `<main>`. */
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<string, string>;
/** 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

View File

@ -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(/(?<!\/)\/.*$/, '/');
this.root = options.target || document.querySelector('main') || document.body;
this.root.innerHTML = buildPageTemplate({
t,
ncThumbnails: this.options.ncThumbnails,
rootUrl,
});
this.toolbar = this.root.querySelector('.toolbar');
this.tbody = this.root.querySelector('main > 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(`<h3>Upload this file?</h3><input type="text" name="paste_name" placeholder="${this.manager.t('New file name')}" />`, (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(/(?<!\/)\/.*$/, '/'),
};
let rows = '';
if (this.manager.url.replace(/\/+$/, '') !== this.manager.client.baseUrl.replace(/\/+$/, '')) {
rows += tpl;
}
for (const entry of entries) {
rows += renderEntry(entry, ctx, entry.isDir);
}
this.tbody.innerHTML = rows;
// Wire up row actions.
this.rows = [];
for (const tr of Array.from(this.tbody.querySelectorAll('tr'))) {
if (tr.classList.contains('parent')) {
this.bindParentRow(tr);
}
else {
this.bindFileRow(tr);
}
}
this.refreshSelection();
}
bindParentRow(tr) {
const a = tr.querySelector('a');
if (!a)
return;
a.onclick = (e) => {
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(`<p class="spinner"><span></span></p>
<h3>${escapeHtml(entry.name)}</h3>
<progress max="${entry.size || 0}"></progress>
<p><span class="progress_bytes"></span> / ${formatBytes(entry.size || 0)}</p>`, 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(`<h3>${this.manager.t('Confirm delete?')}</h3>`, 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(`<input type="text" name="rename" placeholder="${this.manager.t('New file name')}" />`, (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(`<input type="text" name="mkdir" placeholder="${this.manager.t('Directory name')}" />`, (vals) => {
if (!vals.mkdir)
return false;
return this.manager.mkdir(vals.mkdir).then(() => this.manager.reload());
});
}
promptMktext() {
this.openDialog(`<input type="text" name="mkfile" placeholder="${this.manager.t('File name')}" />`, (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(`<input type="text" name="mkfile" placeholder="${this.manager.t('File name')}" />`, 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
? `<div id="mdp"><textarea name="edit" cols="70" rows="30"></textarea><div class="md_preview"></div></div>`
: `<textarea name="edit" cols="70" rows="30"></textarea>`, 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', `<div class="toolbar editor-toolbar">
<label><input type="checkbox" class="autosave" /> ${this.manager.t('Autosave')}</label>
<span class="status"></span>
<input class="save" type="button" value="${this.manager.t('Save and close')}" />
</div>`);
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('<div class="md_preview"></div>', 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 = `<img src="${entry.url}" />`;
else if (/^audio\//.test(type))
html = `<audio controls autoplay src="${entry.url}" />`;
else if (/^video\//.test(type))
html = `<video controls autoplay src="${entry.url}" />`;
else
html = `<iframe src="${entry.url}" />`;
this.openDialog(html, false);
const dlg = this.root.querySelector('dialog');
dlg.className = 'preview';
}
// -- dialogs --------------------------------------------------------
/**
* 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).
*/
openDialog(bodyHtml, withOkOrSubmit, submitFn) {
let withOk;
let cb;
if (typeof withOkOrSubmit === 'function') {
withOk = true;
cb = withOkOrSubmit;
}
else if (typeof withOkOrSubmit === 'boolean') {
withOk = withOkOrSubmit;
cb = submitFn;
}
else {
withOk = true;
cb = submitFn;
}
const okBtn = withOk ? `<p><input type="submit" value="${this.manager.t('OK')}" /></p>` : '';
const html = this.dialogTemplate.replace(/%s/, bodyHtml).replace(/%b/, okBtn);
document.body.classList.add('dialog');
document.body.insertAdjacentHTML('beforeend', html);
const closeBtn = this.root.querySelector('dialog .close input');
if (closeBtn)
closeBtn.onclick = () => this.closeDialog();
this.escapeHandler = (e) => { if (e.key === 'Escape') {
e.preventDefault();
this.closeDialog();
} };
window.addEventListener('keyup', this.escapeHandler);
const firstInput = this.root.querySelector('dialog form input, dialog form textarea');
firstInput?.focus();
const form = this.root.querySelector('dialog form');
form.onsubmit = (e) => {
e.preventDefault();
if (!cb) {
this.closeDialog();
return false;
}
const vals = {};
form.querySelectorAll('input[name], textarea[name]').forEach((el) => {
if (el.name)
vals[el.name] = el.value;
});
const result = cb(vals);
if (result === false)
return false;
if (result instanceof Promise) {
result.then(() => this.closeDialog()).catch((err) => {
console.error(err);
alert(err.message);
});
}
else {
this.closeDialog();
}
return false;
};
}
closeDialog() {
if (!document.body.classList.contains('dialog'))
return;
if (this.currentXhr) {
this.currentXhr.abort();
this.currentXhr = null;
}
document.body.classList.remove('dialog');
const dlg = document.querySelector('dialog');
if (dlg)
dlg.remove();
if (this.escapeHandler) {
window.removeEventListener('keyup', this.escapeHandler);
this.escapeHandler = null;
}
}
// -- misc helpers ---------------------------------------------------
setLoading(on) {
document.body.classList.toggle('loading', on);
}
on(target, type, selector, handler) {
const fn = (e) => {
const el = e.target?.closest(selector);
if (el && target.contains(el)) {
handler(e, el);
}
};
target.addEventListener(type, fn);
this.unbindings.push(() => target.removeEventListener(type, fn));
}
show(selector) { this.root.querySelectorAll(selector).forEach((e) => e.style.display = ''); }
hide(selector) { this.root.querySelectorAll(selector).forEach((e) => e.style.display = 'none'); }
toggle(selector, on) { on ? this.show(selector) : this.hide(selector); }
}
/** Built-in minimal markdown converter (no external deps). */
function identityMarkdown(s) {
return s
.replace(/\r\n|\r/g, '\n')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/^(#+)\s*(.+)$/mg, (_m, h, t) => '<h' + h.length + '>' + t + '</h' + h.length + '>')
.replace(/\n{2,}/g, '<p>')
.replace(/\[(.*)\]\((.*)\)/g, (_m, l, h) => '<a href="' + h + '">' + (l || h) + '</a>');
}
/** HTML-escape (used in templates and dialogs). */
function escapeHtml(s) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
//# sourceMappingURL=component.js.map

View File

@ -0,0 +1,42 @@
/**
* A small text-editor dialog with optional markdown preview and autosave.
*
* The editor is a thin layer over a `<textarea>` and is decoupled from the
* rest of the UI: it just needs a `request` function (PUT) and a
* `markdownToHTML` converter. Both default to no-ops so the editor works
* even without marked.js loaded.
*/
export interface TextEditorDeps {
/** The file being edited. */
file: {
url: string;
name: string;
};
/** Translate a key into the user's language. */
translate: (key: string) => string;
/** Issue a request and return the response. */
request: (method: string, url: string, body?: BodyInit | null) => Promise<Response>;
/** Convert markdown text to HTML (default: identity). */
markdownToHTML?: (text: string) => string;
/** Called when the user clicks close and the dialog is dismissed. */
onClose?: () => void;
}
export declare class TextEditor {
private readonly deps;
private originalContent;
private currentContent;
private autoSaveEnabled;
private autoSaveInterval;
private preventCloseFn;
constructor(deps: TextEditorDeps);
/** Bind to a textarea element. */
bind(textEl: HTMLTextAreaElement, saveBtn?: HTMLButtonElement, closeBtn?: HTMLButtonElement, autosaveCheckbox?: HTMLInputElement): void;
/** Save the current content to the file URL. */
save(content: string): Promise<void>;
/** Update preview pane (if `markdownToHTML` is configured). */
bindPreview(textEl: HTMLTextAreaElement, previewEl: HTMLElement): void;
/** Tear down all listeners. */
dispose(): void;
private handleClose;
}
//# sourceMappingURL=editor.d.ts.map

View File

@ -0,0 +1,117 @@
/**
* A small text-editor dialog with optional markdown preview and autosave.
*
* The editor is a thin layer over a `<textarea>` and is decoupled from the
* rest of the UI: it just needs a `request` function (PUT) and a
* `markdownToHTML` converter. Both default to no-ops so the editor works
* even without marked.js loaded.
*/
export class TextEditor {
constructor(deps) {
this.deps = deps;
this.originalContent = '';
this.currentContent = '';
this.autoSaveEnabled = false;
this.autoSaveInterval = null;
this.preventCloseFn = null;
if (typeof localStorage !== 'undefined') {
this.autoSaveEnabled = localStorage.getItem('autosave') === '1' || localStorage.getItem('autosave') === 'true';
}
}
/** Bind to a textarea element. */
bind(textEl, saveBtn, closeBtn, autosaveCheckbox) {
this.originalContent = textEl.value;
this.currentContent = textEl.value;
if (saveBtn) {
saveBtn.onclick = () => {
this.save(textEl.value).then(() => {
this.originalContent = textEl.value;
this.handleClose(textEl);
});
};
}
if (closeBtn) {
closeBtn.onclick = () => this.handleClose(textEl);
}
if (autosaveCheckbox) {
autosaveCheckbox.checked = this.autoSaveEnabled;
autosaveCheckbox.onchange = () => {
this.autoSaveEnabled = autosaveCheckbox.checked;
try {
localStorage.setItem('autosave', this.autoSaveEnabled ? '1' : '0');
}
catch {
/* ignore */
}
};
}
textEl.onkeydown = (e) => {
if (e.ctrlKey && e.key === 's') {
e.preventDefault();
this.save(textEl.value).then(() => {
this.originalContent = textEl.value;
});
}
else if (e.key === 'Escape') {
e.preventDefault();
this.handleClose(textEl);
}
};
textEl.onkeyup = () => {
this.currentContent = textEl.value;
};
this.preventCloseFn = (e) => {
if (this.currentContent === this.originalContent) {
return;
}
e.preventDefault();
e.returnValue = '';
};
window.addEventListener('beforeunload', this.preventCloseFn, { capture: true });
this.autoSaveInterval = window.setInterval(() => {
if (this.autoSaveEnabled && this.currentContent !== this.originalContent) {
this.save(textEl.value).then(() => {
this.originalContent = textEl.value;
});
}
}, 10000);
}
/** Save the current content to the file URL. */
save(content) {
this.currentContent = content;
return this.deps.request('PUT', this.deps.file.url, content).then(() => undefined);
}
/** Update preview pane (if `markdownToHTML` is configured). */
bindPreview(textEl, previewEl) {
if (!this.deps.markdownToHTML) {
return;
}
const render = () => {
previewEl.innerHTML = this.deps.markdownToHTML(textEl.value);
};
textEl.oninput = render;
render();
}
/** Tear down all listeners. */
dispose() {
if (this.autoSaveInterval !== null) {
clearInterval(this.autoSaveInterval);
this.autoSaveInterval = null;
}
if (this.preventCloseFn) {
window.removeEventListener('beforeunload', this.preventCloseFn, { capture: true });
this.preventCloseFn = null;
}
}
handleClose(textEl) {
if (this.currentContent !== this.originalContent) {
if (!confirm(this.deps.translate('Your changes have not been saved. Do you want to cancel WITHOUT saving?'))) {
return;
}
}
this.dispose();
this.deps.onClose?.();
textEl.value = this.originalContent;
}
}
//# sourceMappingURL=editor.js.map

View File

@ -0,0 +1,23 @@
import type { SortOrder, WebDAVEntry } from '../types.js';
export interface TemplateContext {
t: (key: string) => string;
ncThumbnails: boolean;
rootUrl: string;
}
/** Build the static `html_tpl` for the page. */
export declare function buildPageTemplate(ctx: TemplateContext): string;
/** Build the dialog template (with placeholder for body / buttons). */
export declare function buildDialogTemplate(t: (k: string) => string): string;
/** The ".." parent row template. */
export declare function buildParentRow(t: (k: string) => string): string;
/** A directory-row template. */
export declare function buildDirRow(): string;
/** A file-row template. */
export declare function buildFileRow(): string;
/** Render a single file/dir row to HTML. */
export declare function renderEntry(entry: WebDAVEntry, ctx: TemplateContext, isDir: boolean): string;
/** Build a paste widget HTML for the toolbar. */
export declare function buildPasteWidget(t: (k: string) => string, count: number, action: 'copy' | 'move'): string;
/** Re-export the sort-order type so consumers don't need to import from types.ts. */
export type { SortOrder };
//# sourceMappingURL=templates.d.ts.map

View File

@ -0,0 +1,131 @@
/**
* HTML templates used by the default UI. All templates are pure strings that
* use the simple `%key%` substitution exposed in `../utils/format.ts`.
*/
import { template } from '../utils/format.js';
import { formatBytes, formatDate } from '../utils/format.js';
/** Build the static `html_tpl` for the page. */
export function buildPageTemplate(ctx) {
const t = ctx.t;
return `<!DOCTYPE html><html>
<head><title></title><link rel="stylesheet" type="text/css" href="webdav.css" /></head>
<body><main>
<div class="toolbar">
<div class="selected">
<input type="button" class="icon download" value="${t('Download')}" />
<input type="button" class="icon delete" value="${t('Delete')}" />
<input type="button" class="icon cut" value="${t('Cut')}" />
<input type="button" class="icon copy" value="${t('Copy')}" />
</div>
<div class="paste"></div>
<div class="create">
<input type="file" style="display: none;" multiple />
<input class="icon upload" type="button" value="${t('Upload files')}" />
<input class="icon mk" type="button" value="${t('New')}" />
<div class="menu">
<input class="icon mkdir" type="button" value="${t('Directory')}" />
<input class="icon mktext" type="button" value="${t('Text file')}" />
<div class="wopi">
<h5>${t('Office document')}</h5>
<input class="icon ODT" type="button" value="${t('Text')}" />
<input class="icon ODS" type="button" value="${t('Spreadsheet')}" />
<input class="icon ODP" type="button" value="${t('Presentation')}" />
<input class="icon ODG" type="button" value="${t('Drawing')}" />
</div>
</div>
</div>
</div>
<table>
<thead>
<tr>
<td scope="col" class="check"><input type="checkbox" /><label><span></span></label></td>
<td scope="col" class="name" data-sort="name"><button>${t('Name')}</button></td>
<td scope="col" class="size" data-sort="size"><button>${t('Size')}</button></td>
<td scope="col" class="date" data-sort="date"><button>${t('Date')}</button></td>
<td></td>
</tr>
</thead>
<tbody></tbody>
</table>
</main><div class="bg"></div></body></html>`;
}
/** Build the dialog template (with placeholder for body / buttons). */
export function buildDialogTemplate(t) {
return `<dialog open><p class="close"><input type="button" value="&#x2716; ${t('Close')}" class="close" /></p><form><div>%s</div>%b</form></dialog>`;
}
/** The ".." parent row template. */
export function buildParentRow(t) {
return `<tr class="parent">
<td class="check"></td>
<th colspan="2"><a href="../"><span class="icon parent"><b></b></span> ${t('..')}</a></th>
<td class="date"></td>
<td class="buttons"></td>
</tr>`;
}
/** A directory-row template. */
export function buildDirRow() {
return `<tr data-permissions="%permissions%" class="%class%" data-name="%name%">
<td class="check"><input type="checkbox" name="delete" value="%uri%" /><label><span></span></label></td>
<th colspan="2"><a href="%uri%">%thumb% %name%</a></th>
<td class="date">%modified%</td>
<td class="buttons"><div>${RENAME_BTN}${DELETE_BTN}</div></td>
</tr>`;
}
/** A file-row template. */
export function buildFileRow() {
return `<tr data-permissions="%permissions%" data-mime="%mime%" data-size="%size%" data-name="%name%">
<td class="check"><input type="checkbox" name="delete" value="%uri%" /><label><span></span></label></td>
<th><a href="%uri%">%thumb% %name%</a></th>
<td class="size">%size_bytes%</td>
<td class="date">%modified%</td>
<td class="buttons"><div>${EDIT_BTN}${DOWNLOAD_BTN}${RENAME_BTN}${DELETE_BTN}</div></td>
</tr>`;
}
const ICON_TPL = `<span class="icon %icon%"><b>%icon%</b></span>`;
const IMAGE_THUMB_TPL = `<img src="%rootUrl%index.php/apps/files/api/v1/thumbnail/150/150/%path%" alt="" />`;
const RENAME_BTN = `<input class="icon rename" type="button" value="%label%" title="%label%" />`;
const DELETE_BTN = `<input class="icon delete" type="button" value="%label%" title="%label%" />`;
const EDIT_BTN = `<input class="icon edit" type="button" value="%label%" title="%label%" />`;
const DOWNLOAD_BTN = `<a download title="%label%" class="btn download">%label%</a>`;
/** Render a single file/dir row to HTML. */
export function renderEntry(entry, ctx, isDir) {
const tpl = isDir ? buildDirRow() : buildFileRow();
let ext = '';
if (!isDir) {
const dot = entry.uri.lastIndexOf('.');
if (dot > -1) {
ext = entry.uri.substring(dot + 1).toUpperCase();
if (ext.length > 4) {
ext = '';
}
}
}
const thumb = entry.mime && entry.mime.startsWith('image/') && ctx.ncThumbnails
? template(IMAGE_THUMB_TPL, { rootUrl: ctx.rootUrl, path: encodeURI(entry.path) })
: template(ICON_TPL, { icon: ext || '' });
return template(tpl, {
uri: entry.uri,
name: entry.name,
permissions: entry.permissions || '',
class: isDir ? 'dir' : 'file',
size: entry.size != null ? String(entry.size) : '',
size_bytes: entry.size != null ? formatBytes(entry.size).replace(/ /g, '&nbsp;') : '',
mime: entry.mime || '',
modified: formatDate(entry.modified),
thumb,
// localized button labels
label: ctx.t('Edit'),
});
}
/** Build a paste widget HTML for the toolbar. */
export function buildPasteWidget(t, count, action) {
const tpl = `<div><strong>${t('%count% files selected')}</strong>
<input type="button" value="%label%" class="icon %action%" />
<input type="button" value="${t('Cancel')}" class="icon cancel" /></div>`;
return template(tpl, {
count: String(count),
action,
label: action === 'copy' ? t('Copy here') : t('Move here'),
});
}
//# sourceMappingURL=templates.js.map

View File

@ -0,0 +1,18 @@
/**
* Fetch wrapper with optional auth header and a sane AbortSignal helper.
*/
/** Headers required/expected for every WebDAV request. */
export type HeaderMap = Record<string, string>;
export interface SendOptions {
method: string;
url: string;
body?: BodyInit | null;
headers?: HeaderMap;
signal?: AbortSignal;
}
/**
* Issue an HTTP request using `fetch` (or a caller-provided `fetch`).
* Returns the raw `Response` so callers can inspect status / stream body.
*/
export declare function sendRequest(fetchImpl: typeof fetch, options: SendOptions, defaultHeaders?: HeaderMap): Promise<Response>;
//# sourceMappingURL=fetch.d.ts.map

View File

@ -0,0 +1,17 @@
/**
* Fetch wrapper with optional auth header and a sane AbortSignal helper.
*/
/**
* Issue an HTTP request using `fetch` (or a caller-provided `fetch`).
* Returns the raw `Response` so callers can inspect status / stream body.
*/
export function sendRequest(fetchImpl, options, defaultHeaders = {}) {
const headers = { ...defaultHeaders, ...(options.headers || {}) };
return fetchImpl(options.url, {
method: options.method,
body: options.body ?? undefined,
headers,
signal: options.signal,
});
}
//# sourceMappingURL=fetch.js.map

View File

@ -0,0 +1,14 @@
/**
* Pure formatting helpers.
*/
/** Substitute `%key%` placeholders in a template with values from `params`. */
export declare function template(tpl: string, params: Record<string, unknown>): string;
/** HTML-escape a string. */
export declare function htmlEscape(unsafe: string): string;
/** Format a byte count as a human-readable string. */
export declare function formatBytes(bytes: number, unit?: string): string;
/** Format a date for display. */
export declare function formatDate(date: Date | null): string;
/** A trivial i18n lookup. Missing keys fall back to the key. */
export declare function makeTranslate(strings: Record<string, string> | undefined): (key: string) => string;
//# sourceMappingURL=format.d.ts.map

View File

@ -0,0 +1,58 @@
/**
* Pure formatting helpers.
*/
/** Substitute `%key%` placeholders in a template with values from `params`. */
export function template(tpl, params) {
return tpl.replace(/%(\w+)%/g, (_match, key) => {
const v = params[key];
return v === undefined || v === null ? '' : String(v);
});
}
/** HTML-escape a string. */
export function htmlEscape(unsafe) {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/** Format a byte count as a human-readable string. */
export function formatBytes(bytes, unit = 'B') {
if (bytes >= 1024 * 1024 * 1024) {
return Math.round(bytes / (1024 * 1024 * 1024)) + ' G' + unit;
}
if (bytes >= 1024 * 1024) {
return Math.round(bytes / (1024 * 1024)) + ' M' + unit;
}
if (bytes >= 1024) {
return Math.round(bytes / 1024) + ' K' + unit;
}
return bytes + ' ' + unit;
}
/** Format a date for display. */
export function formatDate(date) {
if (!date || isNaN(date.getTime())) {
return '';
}
const now = new Date();
const hoursAgo = (now.getTime() - date.getTime()) / 3600 / 1000;
if (date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate()) {
if (hoursAgo <= 1) {
return Math.round(hoursAgo * 60) + ' minutes ago';
}
return Math.round(hoursAgo) + ' hours ago';
}
if (hoursAgo <= 24) {
return 'Yesterday, ' + date.toLocaleTimeString();
}
return date.toLocaleString([], { year: 'numeric', month: 'numeric', day: 'numeric' });
}
/** A trivial i18n lookup. Missing keys fall back to the key. */
export function makeTranslate(strings) {
const dict = strings || {};
return (key) => (key in dict ? dict[key] : key);
}
//# sourceMappingURL=format.js.map

View File

@ -0,0 +1,35 @@
/**
* URL helpers. Pure functions, no DOM access.
*/
/**
* If `url` is not absolute, resolve it against `base`. Otherwise returns `url`.
* Always returns a string ending in a way that matches the input (i.e. trailing
* slashes are preserved for collection URLs).
*/
export declare function normalizeURL(url: string, base: string): string;
/**
* Join a path onto a base URL, ensuring exactly one `/` between them.
* Trailing slashes on `base` are preserved.
*/
export declare function joinURL(base: string, path: string): string;
/** Return everything before the last `/` in `path`. */
export declare function dirname(path: string): string;
/** Return everything after the last `/` in `path`. */
export declare function basename(path: string): string;
/**
* Resolve a parent URL of a collection URL. Strips the trailing slash
* (the collection marker), pops the directory's own name, then re-appends
* the trailing slash.
*
* @example
* parentCollectionURL("https://x/webdav/Music/Albums/") // "https://x/webdav/Music/"
* parentCollectionURL("https://x/webdav/") // "https://x/"
*/
export declare function parentCollectionURL(uri: string): string;
/**
* Strip a leading host prefix from a name (e.g. "example.comFoo" "Foo").
* Used to remove the WebDAV server's hostname from filenames that some servers
* include as a prefix on each entry.
*/
export declare function stripHostPrefix(name: string, host: string | null): string;
//# sourceMappingURL=url.d.ts.map

View File

@ -0,0 +1,63 @@
/**
* URL helpers. Pure functions, no DOM access.
*/
/**
* If `url` is not absolute, resolve it against `base`. Otherwise returns `url`.
* Always returns a string ending in a way that matches the input (i.e. trailing
* slashes are preserved for collection URLs).
*/
export function normalizeURL(url, base) {
if (!/^https?:\/\//.test(url)) {
const baseWithSlash = base.endsWith('/') ? base : base + '/';
return new URL(url, baseWithSlash).href;
}
return url;
}
/**
* Join a path onto a base URL, ensuring exactly one `/` between them.
* Trailing slashes on `base` are preserved.
*/
export function joinURL(base, path) {
const baseWithSlash = base.endsWith('/') ? base : base + '/';
return new URL(path, baseWithSlash).href;
}
/** Return everything before the last `/` in `path`. */
export function dirname(path) {
const idx = path.lastIndexOf('/');
return idx === -1 ? '' : path.slice(0, idx);
}
/** Return everything after the last `/` in `path`. */
export function basename(path) {
const idx = path.lastIndexOf('/');
return idx === -1 ? path : path.slice(idx + 1);
}
/**
* Resolve a parent URL of a collection URL. Strips the trailing slash
* (the collection marker), pops the directory's own name, then re-appends
* the trailing slash.
*
* @example
* parentCollectionURL("https://x/webdav/Music/Albums/") // "https://x/webdav/Music/"
* parentCollectionURL("https://x/webdav/") // "https://x/"
*/
export function parentCollectionURL(uri) {
const trimmed = uri.replace(/\/+$/, '');
const parent = dirname(trimmed);
return parent ? parent + '/' : '';
}
/**
* Strip a leading host prefix from a name (e.g. "example.comFoo" "Foo").
* Used to remove the WebDAV server's hostname from filenames that some servers
* include as a prefix on each entry.
*/
export function stripHostPrefix(name, host) {
if (!name || !host) {
return name;
}
if (name.startsWith(host)) {
const stripped = name.slice(host.length);
return stripped.length ? stripped : host;
}
return name;
}
//# sourceMappingURL=url.js.map

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,17 @@
{
"name": "webdav-component",
"version": "0.1.0",
"type": "module",
"private": true,
"main": "./esm/index.js",
"module": "./esm/index.js",
"exports": {
".": {
"import": "./esm/index.js"
},
"./style.css": "./esm/webdav.css"
},
"sideEffects": [
"**/*.css"
]
}

262
src/app/webdav.ts Normal file
View File

@ -0,0 +1,262 @@
/**
* 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, 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 {
try {
// Absolute URL → take just the pathname.
if (/^https?:\/\//i.test(uri)) {
const u = new URL(uri);
return normalizePath(decodeURIComponent(u.pathname));
}
} catch {
/* ignore */
}
// Already-relative URI: strip a host prefix if the server added one,
// then ensure a leading `/`.
let p = uri;
try {
const host = new URL(baseUrl).hostname;
if (p.startsWith('/' + host + '/')) p = p.slice(host.length + 1);
} catch {
/* ignore */
}
return normalizePath(p);
}
function toEntryInfo(e: WebDAVEntry, baseUrl: string): EntryInfo {
const rawPath = e.path && e.path.length > 0 ? e.path : e.uri;
const fullPath = pathFromUri(rawPath, baseUrl);
return {
name: e.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. */
export async function listDirectory(path: string): Promise<EntryInfo[]> {
const manager = getWebDAVClient();
const base = manager.client.baseUrl;
// The manager's `open` requires an absolute URL on the configured server.
const collectionUrl = pathToUrl(normalizePath(path), base);
const listing = await manager.open(collectionUrl);
const entries: EntryInfo[] = [];
for (const e of Object.values(listing)) {
if (e && e.name) {
entries.push(toEntryInfo(e, base));
}
}
// 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<boolean> {
const manager = getWebDAVClient();
const base = manager.client.baseUrl;
return manager.client.exists(pathToUrl(normalizePath(path), base));
}
export async function createFolder(path: string, _recursive = true): Promise<void> {
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<void> {
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<void> {
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 putFileContents(
path: string,
data: string | ArrayBuffer | Uint8Array | Blob,
): Promise<void> {
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<Blob> {
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();
}
// ----- Helpers --------------------------------------------------------
/** Convert a server-relative path to an absolute URL on the configured base. */
function pathToUrl(p: string, baseUrl: string): string {
// baseUrl may already end with `/`; we just want "<base><path>".
const sep = baseUrl.endsWith('/') || p.startsWith('/') ? '' : '/';
return baseUrl + sep + p;
}
/** 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]}`;
}

View File

@ -272,7 +272,11 @@ class Tag:
self.attrs = splitattrs(self.attrs) self.attrs = splitattrs(self.attrs)
for a in self.attrs: for a in self.attrs:
if a.startswith('xmlns'): if a.startswith('xmlns'):
nsname = a[6:] # `xmlns:D="DAV:"` should register prefix "D" → "DAV:".
# `xmlns="DAV:"` (default ns) registers prefix "" → "DAV:".
nsname = a[5:] # drop "xmlns"; leaves "" (default) or ":D" (prefixed)
if nsname.startswith(':'):
nsname = nsname[1:]
parser.namespaces[nsname] = self.attrs[a] parser.namespaces[nsname] = self.attrs[a]
self.rawname = self.name self.rawname = self.name