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:
parent
79d92dc89d
commit
4a2f6032d2
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -3,3 +3,4 @@ dist/*
|
|||
files/*
|
||||
node_modules/*
|
||||
package-lock.json
|
||||
__pycache__/*
|
||||
|
|
|
|||
|
|
@ -62,8 +62,7 @@
|
|||
"sonner": "2.0.3",
|
||||
"tailwind-merge": "3.2.0",
|
||||
"tw-animate-css": "1.3.8",
|
||||
"vaul": "1.1.2",
|
||||
"webdav": "^5.10.0"
|
||||
"vaul": "1.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "4.1.12",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
import { useState } from 'react';
|
||||
import { Folder, File, ChevronRight, Home } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Folder, File, ChevronRight, Home, RefreshCw, Upload, FolderPlus, Trash2, ArrowLeft, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
createFolder,
|
||||
deletePath,
|
||||
humanFileSize,
|
||||
joinPath,
|
||||
listDirectory,
|
||||
normalizePath,
|
||||
putFileContents,
|
||||
splitPath,
|
||||
type EntryInfo,
|
||||
} from '../webdav';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface FileBrowserProps {
|
||||
currentPath: string;
|
||||
|
|
@ -7,69 +19,50 @@ interface FileBrowserProps {
|
|||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Mode = 'pick-file' | 'pick-folder' | 'browse';
|
||||
|
||||
export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrowserProps) {
|
||||
const [path, setPath] = useState(currentPath || '/');
|
||||
const [path, setPath] = useState(() => normalizePath(currentPath || '/'));
|
||||
const [entries, setEntries] = useState<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 getContents = (currentPath: string) => {
|
||||
const mockFS: Record<string, any[]> = {
|
||||
'/': [
|
||||
{ name: 'sd', type: 'folder' },
|
||||
{ name: 'autoboot.d64', type: 'file' },
|
||||
{ name: 'config.json', type: 'file' }
|
||||
],
|
||||
'/sd': [
|
||||
{ name: 'games', type: 'folder' },
|
||||
{ name: 'demos', type: 'folder' },
|
||||
{ name: 'utilities', type: 'folder' },
|
||||
{ name: 'disk1.d64', type: 'file' },
|
||||
{ name: 'disk2.d81', type: 'file' }
|
||||
],
|
||||
'/sd/games': [
|
||||
{ name: 'arcade', type: 'folder' },
|
||||
{ name: 'adventure', type: 'folder' },
|
||||
{ name: 'game1.d64', type: 'file' },
|
||||
{ name: 'game2.d64', type: 'file' },
|
||||
{ name: 'game3.prg', type: 'file' }
|
||||
],
|
||||
'/sd/demos': [
|
||||
{ name: 'demo1.d64', type: 'file' },
|
||||
{ name: 'demo2.d64', type: 'file' }
|
||||
],
|
||||
'/sd/utilities': [
|
||||
{ name: 'util1.prg', type: 'file' },
|
||||
{ name: 'util2.d64', type: 'file' }
|
||||
],
|
||||
'/sd/games/arcade': [
|
||||
{ name: 'pacman.d64', type: 'file' },
|
||||
{ name: 'galaga.d64', type: 'file' }
|
||||
],
|
||||
'/sd/games/adventure': [
|
||||
{ name: 'zork.d64', type: 'file' },
|
||||
{ name: 'adventure.d64', type: 'file' }
|
||||
]
|
||||
};
|
||||
|
||||
return mockFS[currentPath] || [];
|
||||
const load = async (p: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const items = await listDirectory(p);
|
||||
setEntries(items);
|
||||
} catch (e: any) {
|
||||
const msg = (e && e.message) || 'Failed to load directory';
|
||||
setError(msg);
|
||||
setEntries([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const contents = getContents(path);
|
||||
useEffect(() => {
|
||||
void load(path);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [path]);
|
||||
|
||||
const navigateUp = () => {
|
||||
if (path === '/') return;
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
setPath(parts.length ? '/' + parts.join('/') : '/');
|
||||
setPath(splitPath(path).parent);
|
||||
};
|
||||
|
||||
const navigateToFolder = (folderName: string) => {
|
||||
const newPath = path === '/' ? `/${folderName}` : `${path}/${folderName}`;
|
||||
setPath(newPath);
|
||||
setPath(joinPath(path, folderName));
|
||||
};
|
||||
|
||||
const selectFile = (fileName: string) => {
|
||||
const fullPath = path === '/' ? `/${fileName}` : `${path}/${fileName}`;
|
||||
onSelect(fullPath);
|
||||
const selectFile = (entry: EntryInfo) => {
|
||||
if (entry.type !== 'file') return;
|
||||
onSelect(entry.path);
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
|
@ -78,6 +71,59 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
|||
onClose();
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
void load(path);
|
||||
};
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
const name = newFolderName.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
await createFolder(joinPath(path, name), true);
|
||||
setNewFolderName('');
|
||||
setShowNewFolder(false);
|
||||
toast.success(`Created folder "${name}"`);
|
||||
void load(path);
|
||||
} catch (e: any) {
|
||||
toast.error(`Failed to create folder: ${e?.message || e}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (entry: EntryInfo) => {
|
||||
const ok = window.confirm(
|
||||
entry.type === 'folder'
|
||||
? `Delete folder "${entry.name}" and all its contents?`
|
||||
: `Delete file "${entry.name}"?`,
|
||||
);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deletePath(entry.path);
|
||||
toast.success(`Deleted ${entry.name}`);
|
||||
void load(path);
|
||||
} catch (e: any) {
|
||||
toast.error(`Failed to delete: ${e?.message || e}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
const target = joinPath(path, file.name);
|
||||
try {
|
||||
const buf = await file.arrayBuffer();
|
||||
await putFileContents(target, buf);
|
||||
toast.success(`Uploaded ${file.name}`);
|
||||
void load(path);
|
||||
} catch (e: any) {
|
||||
toast.error(`Failed to upload ${file.name}: ${e?.message || e}`);
|
||||
}
|
||||
};
|
||||
|
||||
const onPickFiles = (e: React.ChangeEvent<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);
|
||||
|
||||
return (
|
||||
|
|
@ -87,15 +133,103 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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>
|
||||
<button onClick={onClose} className="text-sm text-blue-600">
|
||||
Cancel
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<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>
|
||||
|
||||
{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">
|
||||
<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" />
|
||||
</button>
|
||||
{pathParts.map((part, index) => (
|
||||
|
|
@ -116,58 +250,115 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
|||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{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"
|
||||
>
|
||||
<div className="text-neutral-400">
|
||||
<Folder className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-neutral-600">..</span>
|
||||
</button>
|
||||
{loading && (
|
||||
<div className="p-8 text-center text-neutral-500 text-sm flex flex-col items-center gap-2">
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contents.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
if (item.type === 'folder') {
|
||||
navigateToFolder(item.name);
|
||||
} else {
|
||||
selectFile(item.name);
|
||||
}
|
||||
}}
|
||||
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
|
||||
{!loading && error && (
|
||||
<div className="p-4 text-sm">
|
||||
<div className="text-red-600 mb-2">Failed to load directory</div>
|
||||
<div className="text-neutral-500 text-xs break-all">{error}</div>
|
||||
<button
|
||||
onClick={() => void load(path)}
|
||||
className="mt-3 inline-flex items-center gap-1 text-blue-600 text-sm"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" /> Retry
|
||||
</button>
|
||||
</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 className="sticky bottom-0 bg-white border-t border-neutral-200 p-4">
|
||||
<button
|
||||
onClick={selectCurrentFolder}
|
||||
className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg"
|
||||
>
|
||||
Select Folder: {path}
|
||||
</button>
|
||||
{mode === 'pick-folder' ? (
|
||||
<button
|
||||
onClick={selectCurrentFolder}
|
||||
className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg"
|
||||
>
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@ import { useState } from 'react';
|
|||
import { X, Search, HardDrive, Loader2 } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
humanFileSize,
|
||||
listDirectory,
|
||||
type EntryInfo,
|
||||
} from '../webdav';
|
||||
|
||||
interface SearchOverlayProps {
|
||||
config: any;
|
||||
|
|
@ -13,7 +18,26 @@ interface SearchResult {
|
|||
name: string;
|
||||
path: string;
|
||||
type: string;
|
||||
size: string;
|
||||
size: number;
|
||||
sizeText: string;
|
||||
}
|
||||
|
||||
const HARDWARE_FILE_EXTS = new Set([
|
||||
'd64', 'd71', 'd81', 'd82', 'dnp', 't64', 'tap', 'prg', 'p00', 'crt', 'bin', 'g64', 'nib',
|
||||
]);
|
||||
|
||||
function fileExtension(p: string): string {
|
||||
const dot = p.lastIndexOf('.');
|
||||
if (dot < 0) return '';
|
||||
return p.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function detectType(entry: EntryInfo): string {
|
||||
if (entry.type === 'folder') return 'DIR';
|
||||
const ext = fileExtension(entry.name);
|
||||
if (!ext) return 'FILE';
|
||||
if (HARDWARE_FILE_EXTS.has(ext)) return ext.toUpperCase();
|
||||
return ext.toUpperCase();
|
||||
}
|
||||
|
||||
export default function SearchOverlay({ config, setConfig, onClose }: SearchOverlayProps) {
|
||||
|
|
@ -25,40 +49,78 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
|||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [showDeviceMenu, setShowDeviceMenu] = useState<number | null>(null);
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!query.trim()) {
|
||||
toast.error('Please enter a search term');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
setHasSearched(true);
|
||||
setResults([]);
|
||||
setSearchError(null);
|
||||
|
||||
// Simulate search with delay
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
try {
|
||||
const found: SearchResult[] = [];
|
||||
const needle = query.trim().toLowerCase();
|
||||
const max = 500; // safety cap
|
||||
|
||||
// Mock search results
|
||||
const mockResults: SearchResult[] = [
|
||||
{ name: 'Pac-Man.d64', path: '/sd/games/arcade/pacman.d64', type: 'D64', size: '174 KB' },
|
||||
{ name: 'Galaga.d64', path: '/sd/games/arcade/galaga.d64', type: 'D64', size: '174 KB' },
|
||||
{ name: 'Adventure.d64', path: '/sd/games/adventure/adventure.d64', type: 'D64', size: '174 KB' },
|
||||
{ name: 'Zork.d64', path: '/sd/games/adventure/zork.d64', type: 'D64', size: '174 KB' },
|
||||
{ name: 'Demo1.d64', path: '/sd/demos/demo1.d64', type: 'D64', size: '174 KB' },
|
||||
{ name: 'Utility.prg', path: '/sd/utilities/util1.prg', type: 'PRG', size: '12 KB' }
|
||||
].filter(result =>
|
||||
result.name.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
// BFS through the WebDAV tree.
|
||||
const queue: string[] = ['/'];
|
||||
const seen = new Set<string>();
|
||||
while (queue.length > 0 && found.length < max) {
|
||||
const dir = queue.shift()!;
|
||||
if (seen.has(dir)) continue;
|
||||
seen.add(dir);
|
||||
let items;
|
||||
try {
|
||||
items = await listDirectory(dir);
|
||||
} catch {
|
||||
// Skip directories we cannot read (permission/404/etc.) and keep going.
|
||||
continue;
|
||||
}
|
||||
for (const it of items) {
|
||||
if (it.type === 'folder') {
|
||||
queue.push(it.path);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
it.name.toLowerCase().includes(needle) ||
|
||||
it.path.toLowerCase().includes(needle)
|
||||
) {
|
||||
found.push({
|
||||
name: it.name,
|
||||
path: it.path,
|
||||
type: detectType(it),
|
||||
size: it.size,
|
||||
sizeText: humanFileSize(it.size),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setResults(mockResults);
|
||||
setIsSearching(false);
|
||||
// Sort results: closest match by name first, then by path length, then alpha.
|
||||
found.sort((a, b) => {
|
||||
const an = a.name.toLowerCase();
|
||||
const bn = b.name.toLowerCase();
|
||||
const aStarts = an.startsWith(needle) ? 0 : 1;
|
||||
const bStarts = bn.startsWith(needle) ? 0 : 1;
|
||||
if (aStarts !== bStarts) return aStarts - bStarts;
|
||||
if (a.path.length !== b.path.length) return a.path.length - b.path.length;
|
||||
return an.localeCompare(bn);
|
||||
});
|
||||
|
||||
setResults(found);
|
||||
} catch (e: any) {
|
||||
setSearchError((e && e.message) || 'Search failed');
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMount = (deviceNum: string, result: SearchResult) => {
|
||||
const newConfig = JSON.parse(JSON.stringify(config));
|
||||
|
||||
// Update the device URL
|
||||
if (newConfig.iec?.devices?.drive?.[deviceNum]) {
|
||||
newConfig.iec.devices.drive[deviceNum].url = result.path;
|
||||
setConfig(newConfig);
|
||||
|
|
@ -68,7 +130,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
|||
};
|
||||
|
||||
const getAvailableDevices = () => {
|
||||
const devices = [];
|
||||
const devices: { number: string; name: string; url?: string }[] = [];
|
||||
if (config.iec?.devices?.drive) {
|
||||
for (const [num, device] of Object.entries(config.iec.devices.drive)) {
|
||||
if (num !== 'vdrive' && num !== 'rom' && (device as any).enabled) {
|
||||
|
|
@ -79,6 +141,10 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
|||
return devices;
|
||||
};
|
||||
|
||||
// Suppress unused-var warnings for fields the UI exposes but doesn't yet
|
||||
// map to a real filter (the WebDAV server doesn't carry these as metadata).
|
||||
void systemType; void videoStandard; void language;
|
||||
|
||||
const availableDevices = getAvailableDevices();
|
||||
|
||||
return (
|
||||
|
|
@ -174,11 +240,17 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
|||
{isSearching && (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!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">
|
||||
{results.length > 0 ? (
|
||||
<>
|
||||
|
|
@ -188,7 +260,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
|||
<div className="space-y-2">
|
||||
{results.map((result, index) => (
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<div className="text-xs text-neutral-500 flex-shrink-0">
|
||||
{result.type} • {result.size}
|
||||
{result.type} · {result.sizeText}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative ml-3">
|
||||
|
|
@ -244,7 +316,7 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
|||
{!hasSearched && (
|
||||
<div className="text-center py-12 text-neutral-400">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { useState } from 'react';
|
||||
import { HardDrive, Activity, Wifi, Signal, Clock, RefreshCw, FolderOpen, Map } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { HardDrive, Activity, Wifi, Signal, Clock, RefreshCw, FolderOpen, Map, Loader2 } from 'lucide-react';
|
||||
import DeviceDetailOverlay from './DeviceDetailOverlay';
|
||||
import { ImageWithFallback } from './figma/ImageWithFallback';
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from './ui/dialog';
|
||||
import DirectoryListing from './DirectoryListing';
|
||||
import { listDirectory, normalizePath, splitPath, type EntryInfo } from '../webdav';
|
||||
|
||||
interface StatusPageProps {
|
||||
config: any;
|
||||
|
|
@ -63,6 +64,47 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
|
|||
const [showDirectory, setShowDirectory] = useState(false);
|
||||
const [showDiskMap, setShowDiskMap] = useState(false);
|
||||
|
||||
// Real directory contents for the active device's mounted file.
|
||||
// Pulled from the WebDAV server (parent folder of the mounted image).
|
||||
const [dirEntries, setDirEntries] = useState<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 (
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto flex flex-col">
|
||||
{(() => {
|
||||
// Derive a directory listing for the currently mounted file.
|
||||
// In a real device this would come from reading the disk's
|
||||
// BAM/ directory sectors. Here we synthesize a plausible
|
||||
// listing based on the mounted file's name.
|
||||
const fileName = activeDevice.url ? activeDevice.url.split('/').pop() : '';
|
||||
if (!fileName) {
|
||||
return (
|
||||
<div className="p-8 text-center text-neutral-500 text-sm">
|
||||
No file mounted on this device.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{!activeDevice?.url && (
|
||||
<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)
|
||||
const mockEntries = [
|
||||
{ blocks: 42, name: 'PAC-MAN', type: 'PRG' },
|
||||
{ blocks: 38, name: 'GALAGA', type: 'PRG' },
|
||||
{ blocks: 21, name: 'HISCORE', type: 'SEQ' },
|
||||
{ blocks: 12, name: 'LOADER', type: 'PRG' },
|
||||
{ blocks: 5, name: 'TITLE-SCREEN', type: 'SEQ' },
|
||||
{ blocks: 3, name: 'CONFIG', type: 'SEQ' },
|
||||
{ blocks: 1, name: 'PARTICLES', type: 'PRG' },
|
||||
];
|
||||
{activeDevice?.url && dirLoading && (
|
||||
<div className="p-8 text-center text-neutral-500 text-sm flex flex-col items-center gap-2">
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
Loading directory from WebDAV…
|
||||
</div>
|
||||
)}
|
||||
|
||||
const totalBlocks = 664; // standard D64 capacity
|
||||
const usedBlocks = mockEntries.reduce((sum, e) => sum + e.blocks, 0);
|
||||
const freeBlocks = totalBlocks - usedBlocks;
|
||||
{activeDevice?.url && !dirLoading && dirError && (
|
||||
<div className="p-4 text-sm">
|
||||
<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 (
|
||||
<>
|
||||
<div
|
||||
className="px-4 py-2 bg-neutral-100 text-xs text-neutral-600 border-b border-neutral-200 flex items-center"
|
||||
style={{ fontFamily: "'C64_Pro_Mono', monospace" }}
|
||||
>
|
||||
<span className="inline-block w-16">BLOCKS</span>
|
||||
<span className="inline-block w-40">NAME</span>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{activeDevice?.url && !dirLoading && !dirError && (
|
||||
<DirectoryListing
|
||||
entries={dirEntries.map((e) => ({
|
||||
name: e.name,
|
||||
type: e.type === 'folder' ? 'DIR' : (e.name.split('.').pop() || 'FILE').toUpperCase(),
|
||||
blocks: e.type === 'file' ? Math.max(1, Math.ceil(e.size / 254)) : 0,
|
||||
}))}
|
||||
footerNote={`${dirEntries.length} ENTRIES · ${directoryPath ?? ''}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
57
src/app/vendor/webdav-component/esm/index.d.ts
vendored
Normal file
57
src/app/vendor/webdav-component/esm/index.d.ts
vendored
Normal 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
|
||||
53
src/app/vendor/webdav-component/esm/index.js
vendored
Normal file
53
src/app/vendor/webdav-component/esm/index.js
vendored
Normal 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
|
||||
116
src/app/vendor/webdav-component/esm/operations/manager.d.ts
vendored
Normal file
116
src/app/vendor/webdav-component/esm/operations/manager.d.ts
vendored
Normal 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
|
||||
304
src/app/vendor/webdav-component/esm/operations/manager.js
vendored
Normal file
304
src/app/vendor/webdav-component/esm/operations/manager.js
vendored
Normal 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
|
||||
27
src/app/vendor/webdav-component/esm/operations/wopi.d.ts
vendored
Normal file
27
src/app/vendor/webdav-component/esm/operations/wopi.d.ts
vendored
Normal 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
|
||||
78
src/app/vendor/webdav-component/esm/operations/wopi.js
vendored
Normal file
78
src/app/vendor/webdav-component/esm/operations/wopi.js
vendored
Normal 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
|
||||
64
src/app/vendor/webdav-component/esm/protocol/client.d.ts
vendored
Normal file
64
src/app/vendor/webdav-component/esm/protocol/client.d.ts
vendored
Normal 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
|
||||
138
src/app/vendor/webdav-component/esm/protocol/client.js
vendored
Normal file
138
src/app/vendor/webdav-component/esm/protocol/client.js
vendored
Normal 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
|
||||
19
src/app/vendor/webdav-component/esm/protocol/parse.d.ts
vendored
Normal file
19
src/app/vendor/webdav-component/esm/protocol/parse.d.ts
vendored
Normal 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
|
||||
76
src/app/vendor/webdav-component/esm/protocol/parse.js
vendored
Normal file
76
src/app/vendor/webdav-component/esm/protocol/parse.js
vendored
Normal 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
|
||||
18
src/app/vendor/webdav-component/esm/protocol/permissions.d.ts
vendored
Normal file
18
src/app/vendor/webdav-component/esm/protocol/permissions.d.ts
vendored
Normal 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
|
||||
23
src/app/vendor/webdav-component/esm/protocol/permissions.js
vendored
Normal file
23
src/app/vendor/webdav-component/esm/protocol/permissions.js
vendored
Normal 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
|
||||
103
src/app/vendor/webdav-component/esm/types.d.ts
vendored
Normal file
103
src/app/vendor/webdav-component/esm/types.d.ts
vendored
Normal 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
|
||||
15
src/app/vendor/webdav-component/esm/types.js
vendored
Normal file
15
src/app/vendor/webdav-component/esm/types.js
vendored
Normal 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
|
||||
103
src/app/vendor/webdav-component/esm/ui/component.d.ts
vendored
Normal file
103
src/app/vendor/webdav-component/esm/ui/component.d.ts
vendored
Normal 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
|
||||
761
src/app/vendor/webdav-component/esm/ui/component.js
vendored
Normal file
761
src/app/vendor/webdav-component/esm/ui/component.js
vendored
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
//# sourceMappingURL=component.js.map
|
||||
42
src/app/vendor/webdav-component/esm/ui/editor.d.ts
vendored
Normal file
42
src/app/vendor/webdav-component/esm/ui/editor.d.ts
vendored
Normal 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
|
||||
117
src/app/vendor/webdav-component/esm/ui/editor.js
vendored
Normal file
117
src/app/vendor/webdav-component/esm/ui/editor.js
vendored
Normal 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
|
||||
23
src/app/vendor/webdav-component/esm/ui/templates.d.ts
vendored
Normal file
23
src/app/vendor/webdav-component/esm/ui/templates.d.ts
vendored
Normal 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
|
||||
131
src/app/vendor/webdav-component/esm/ui/templates.js
vendored
Normal file
131
src/app/vendor/webdav-component/esm/ui/templates.js
vendored
Normal 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="✖ ${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, ' ') : '',
|
||||
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
|
||||
18
src/app/vendor/webdav-component/esm/utils/fetch.d.ts
vendored
Normal file
18
src/app/vendor/webdav-component/esm/utils/fetch.d.ts
vendored
Normal 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
|
||||
17
src/app/vendor/webdav-component/esm/utils/fetch.js
vendored
Normal file
17
src/app/vendor/webdav-component/esm/utils/fetch.js
vendored
Normal 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
|
||||
14
src/app/vendor/webdav-component/esm/utils/format.d.ts
vendored
Normal file
14
src/app/vendor/webdav-component/esm/utils/format.d.ts
vendored
Normal 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
|
||||
58
src/app/vendor/webdav-component/esm/utils/format.js
vendored
Normal file
58
src/app/vendor/webdav-component/esm/utils/format.js
vendored
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
/** 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
|
||||
35
src/app/vendor/webdav-component/esm/utils/url.d.ts
vendored
Normal file
35
src/app/vendor/webdav-component/esm/utils/url.d.ts
vendored
Normal 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
|
||||
63
src/app/vendor/webdav-component/esm/utils/url.js
vendored
Normal file
63
src/app/vendor/webdav-component/esm/utils/url.js
vendored
Normal 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
|
||||
1010
src/app/vendor/webdav-component/esm/webdav.css
vendored
Normal file
1010
src/app/vendor/webdav-component/esm/webdav.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
17
src/app/vendor/webdav-component/package.json
vendored
Normal file
17
src/app/vendor/webdav-component/package.json
vendored
Normal 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
262
src/app/webdav.ts
Normal 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]}`;
|
||||
}
|
||||
|
|
@ -272,7 +272,11 @@ class Tag:
|
|||
self.attrs = splitattrs(self.attrs)
|
||||
for a in self.attrs:
|
||||
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]
|
||||
self.rawname = self.name
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user