feat(SearchOverlay): enhance search functionality with scanning and improved result display
This commit is contained in:
parent
7c25680091
commit
30e3cea442
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { X, Search, HardDrive, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { X, Search, Loader2, RefreshCw, FolderSearch, File, Folder } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { toast } from 'sonner';
|
||||
import { humanFileSize } from '../webdav';
|
||||
|
|
@ -9,7 +9,9 @@ import {
|
|||
searchLocate,
|
||||
isLocateDbLoaded,
|
||||
resetLocateDb,
|
||||
buildLocateDb,
|
||||
type LocateEntry,
|
||||
type ScanPhase,
|
||||
} from '../locate-db';
|
||||
|
||||
interface SearchOverlayProps {
|
||||
|
|
@ -24,11 +26,12 @@ interface SearchResult {
|
|||
type: string;
|
||||
size: number;
|
||||
sizeText: string;
|
||||
isDir: boolean;
|
||||
}
|
||||
|
||||
const HARDWARE_FILE_EXTS = new Set([
|
||||
'd64', 'd71', 'd81', 'd82', 'dnp', 't64', 'tap', 'prg', 'p00', 'crt', 'bin', 'g64', 'nib',
|
||||
]);
|
||||
const DISK_EXTS = new Set(['d64','d71','d81','d82','dnp','t64','tap','g64','nib']);
|
||||
const CART_EXTS = new Set(['crt','bin']);
|
||||
const PRG_EXTS = new Set(['prg','p00']);
|
||||
|
||||
function fileExtension(p: string): string {
|
||||
const dot = p.lastIndexOf('.');
|
||||
|
|
@ -36,18 +39,32 @@ function fileExtension(p: string): string {
|
|||
}
|
||||
|
||||
function entryToResult(e: LocateEntry): SearchResult {
|
||||
if (e.is_dir) return { name: e.name, path: e.path, type: 'DIR', size: e.size, sizeText: '—' };
|
||||
if (e.is_dir) return { name: e.name, path: e.path, type: 'DIR', size: 0, sizeText: '—', isDir: true };
|
||||
const ext = fileExtension(e.name);
|
||||
const type = ext ? (HARDWARE_FILE_EXTS.has(ext) ? ext.toUpperCase() : ext.toUpperCase()) : 'FILE';
|
||||
return { name: e.name, path: e.path, type, size: e.size, sizeText: humanFileSize(e.size) };
|
||||
return { name: e.name, path: e.path, type: ext ? ext.toUpperCase() : 'FILE', size: e.size, sizeText: humanFileSize(e.size), isDir: false };
|
||||
}
|
||||
|
||||
function TypeBadge({ type, isDir }: { type: string; isDir: boolean }) {
|
||||
if (isDir) return <span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-mono">DIR</span>;
|
||||
const ext = type.toLowerCase();
|
||||
if (DISK_EXTS.has(ext)) return <span className="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 font-mono">{type}</span>;
|
||||
if (CART_EXTS.has(ext)) return <span className="text-xs px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 font-mono">{type}</span>;
|
||||
if (PRG_EXTS.has(ext)) return <span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 font-mono">{type}</span>;
|
||||
return <span className="text-xs px-1.5 py-0.5 rounded bg-neutral-100 text-neutral-600 font-mono">{type}</span>;
|
||||
}
|
||||
|
||||
function scanPhaseLabel(phase: ScanPhase, value: number): string {
|
||||
if (phase === 'scanning') return `Scanning… ${humanFileSize(value)}`;
|
||||
if (phase === 'building') return `Building… ${value.toLocaleString()} entries`;
|
||||
return `Saving… ${humanFileSize(value)}`;
|
||||
}
|
||||
|
||||
export default function SearchOverlay({ config, setConfig, onClose }: SearchOverlayProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [systemType, setSystemType] = useState('all');
|
||||
const [videoStandard, setVideoStandard] = useState('all');
|
||||
const [language, setLanguage] = useState('all');
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [scanPhase, setScanPhase] = useState<ScanPhase | null>(null);
|
||||
const [scanValue, setScanValue] = useState(0);
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [showDeviceMenu, setShowDeviceMenu] = useState<number | null>(null);
|
||||
|
|
@ -55,9 +72,6 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
|||
const [dbBytes, setDbBytes] = useState<number | null>(null);
|
||||
const [dbPhase, setDbPhase] = useState<'idle' | 'downloading' | 'ready'>('idle');
|
||||
|
||||
void systemType; void videoStandard; void language;
|
||||
|
||||
// Show "ready" phase if DB was already loaded from a previous search.
|
||||
useEffect(() => {
|
||||
if (isLocateDbLoaded()) setDbPhase('ready');
|
||||
}, []);
|
||||
|
|
@ -68,7 +82,6 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
|||
setHasSearched(true);
|
||||
setResults([]);
|
||||
setSearchError(null);
|
||||
|
||||
try {
|
||||
if (!isLocateDbLoaded()) {
|
||||
setDbPhase('downloading');
|
||||
|
|
@ -76,19 +89,15 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
|||
await openLocateDb(bytes => flushSync(() => setDbBytes(bytes)));
|
||||
setDbPhase('ready');
|
||||
}
|
||||
|
||||
const needle = query.trim();
|
||||
const entries = searchLocate(needle);
|
||||
|
||||
// Sort: name starts with query first, then by path depth (shorter = closer to root).
|
||||
const lower = needle.toLowerCase();
|
||||
entries.sort((a, b) => {
|
||||
const lower = needle.toLowerCase();
|
||||
const aStart = a.name.toLowerCase().startsWith(lower) ? 0 : 1;
|
||||
const bStart = b.name.toLowerCase().startsWith(lower) ? 0 : 1;
|
||||
if (aStart !== bStart) return aStart - bStart;
|
||||
return a.path.length - b.path.length;
|
||||
});
|
||||
|
||||
setResults(entries.map(entryToResult));
|
||||
} catch (e: any) {
|
||||
setSearchError(e?.message ?? 'Search failed');
|
||||
|
|
@ -99,7 +108,25 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
|||
}
|
||||
};
|
||||
|
||||
const handleRefreshDb = async () => {
|
||||
const handleScan = async () => {
|
||||
setIsScanning(true);
|
||||
setScanPhase('scanning');
|
||||
setScanValue(0);
|
||||
try {
|
||||
const { count, bytes } = await buildLocateDb((phase, value) =>
|
||||
flushSync(() => { setScanPhase(phase); setScanValue(value); })
|
||||
);
|
||||
toast.success(`Indexed ${count.toLocaleString()} items · ${humanFileSize(bytes)}`);
|
||||
setDbPhase('ready');
|
||||
} catch (e: any) {
|
||||
toast.error(`Scan failed: ${e?.message ?? e}`);
|
||||
} finally {
|
||||
setIsScanning(false);
|
||||
setScanPhase(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshDb = () => {
|
||||
resetLocateDb();
|
||||
setDbPhase('idle');
|
||||
setHasSearched(false);
|
||||
|
|
@ -117,170 +144,176 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
|||
}
|
||||
};
|
||||
|
||||
const getAvailableDevices = () => {
|
||||
const devices: { number: string; name: string; url?: string }[] = [];
|
||||
const availableDevices = (() => {
|
||||
const out: { number: string }[] = [];
|
||||
if (config.devices?.iec) {
|
||||
for (const [num, device] of Object.entries(config.devices.iec)) {
|
||||
const d = device as any;
|
||||
if (d.type === 'drive' && d.enabled) {
|
||||
devices.push({ number: num, name: `Drive ${num}`, url: d.url });
|
||||
}
|
||||
for (const [num, d] of Object.entries(config.devices.iec)) {
|
||||
if ((d as any).type === 'drive' && (d as any).enabled) out.push({ number: num });
|
||||
}
|
||||
}
|
||||
return devices;
|
||||
};
|
||||
|
||||
const availableDevices = getAvailableDevices();
|
||||
return out;
|
||||
})();
|
||||
|
||||
const loadingLabel = dbPhase === 'downloading'
|
||||
? (dbBytes === null ? 'Loading database…' : `Loading database… ${humanFileSize(dbBytes)}`)
|
||||
: 'Searching…';
|
||||
|
||||
const busy = isSearching || isScanning;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 backdrop-blur-md bg-black/40 flex items-center justify-center"
|
||||
className="fixed inset-0 z-50 backdrop-blur-md bg-black/40"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: -50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -50, opacity: 0 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="w-full h-full bg-white/50 shadow-2xl overflow-auto flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
|
||||
className="fixed inset-x-0 bottom-0 top-12 bg-white rounded-t-2xl shadow-2xl flex flex-col overflow-hidden"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-4 sm:p-6 flex-1 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-medium">Search</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{dbPhase === 'ready' && (
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 px-4 pt-4 pb-3 border-b border-neutral-100">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-base font-semibold text-neutral-800">Search</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
{dbPhase === 'ready' && !busy && (
|
||||
<button
|
||||
onClick={handleRefreshDb}
|
||||
className="p-2 -m-2 hover:bg-neutral-100 rounded-lg text-neutral-400"
|
||||
className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600 transition-colors"
|
||||
title="Reload database"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onClose} className="p-2 -m-2 hover:bg-neutral-100 rounded-lg">
|
||||
<X className="w-6 h-6" />
|
||||
<button
|
||||
onClick={handleScan}
|
||||
disabled={busy}
|
||||
className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600 disabled:opacity-40 transition-colors"
|
||||
title="Scan /sd and rebuild database"
|
||||
>
|
||||
<FolderSearch className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-500 ml-1">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex gap-2">
|
||||
{/* Search input */}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="Search for games, programs, files..."
|
||||
className="flex-1 px-4 py-3 border border-neutral-300 rounded-lg text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && !busy && handleSearch()}
|
||||
placeholder="Search games, programs, files…"
|
||||
className="w-full pl-9 pr-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-colors"
|
||||
autoFocus
|
||||
disabled={busy}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={isSearching}
|
||||
className="p-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={busy || !query.trim()}
|
||||
className="px-4 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-medium hover:bg-blue-700 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||
<div>
|
||||
<label className="text-sm text-neutral-500 block mb-1">System Type</label>
|
||||
<select value={systemType} onChange={(e) => setSystemType(e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg text-sm">
|
||||
<option value="all">All Systems</option>
|
||||
<option value="c64">C64</option>
|
||||
<option value="c128">C128</option>
|
||||
<option value="vic20">VIC-20</option>
|
||||
<option value="plus4">Plus/4</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-neutral-500 block mb-1">Video Standard</label>
|
||||
<select value={videoStandard} onChange={(e) => setVideoStandard(e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg text-sm">
|
||||
<option value="all">All Standards</option>
|
||||
<option value="ntsc">NTSC</option>
|
||||
<option value="pal">PAL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-neutral-500 block mb-1">Language</label>
|
||||
<select value={language} onChange={(e) => setLanguage(e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg text-sm">
|
||||
<option value="all">All Languages</option>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Spanish</option>
|
||||
<option value="de">German</option>
|
||||
<option value="fr">French</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/* DB status chip */}
|
||||
{dbPhase === 'ready' && !busy && (
|
||||
<p className="text-xs text-green-600 mt-2 flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
|
||||
Database ready
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Scanning progress */}
|
||||
{isScanning && scanPhase && (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
|
||||
<p className="text-sm font-medium text-neutral-700">{scanPhaseLabel(scanPhase, scanValue)}</p>
|
||||
<p className="text-xs text-neutral-400">Scanning /sd recursively…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Searching / loading DB progress */}
|
||||
{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">{loadingLabel}</div>
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
|
||||
<p className="text-sm text-neutral-500">{loadingLabel}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSearching && searchError && (
|
||||
<div className="text-center py-12 text-red-600">Search failed: {searchError}</div>
|
||||
{/* Error */}
|
||||
{!busy && searchError && (
|
||||
<div className="p-6 text-center">
|
||||
<p className="text-sm text-red-600">{searchError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSearching && !searchError && hasSearched && (
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{/* Results */}
|
||||
{!busy && !searchError && hasSearched && (
|
||||
<div className="p-3">
|
||||
{results.length > 0 ? (
|
||||
<>
|
||||
<div className="text-sm text-neutral-500 mb-3">
|
||||
{results.length} result{results.length !== 1 ? 's' : ''} found
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-neutral-400 px-1 mb-2">
|
||||
{results.length} result{results.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{results.map((result, index) => (
|
||||
<div
|
||||
key={result.path}
|
||||
className="bg-neutral-50 border border-neutral-200 rounded-lg p-4 flex items-center justify-between hover:bg-neutral-100"
|
||||
className="bg-neutral-50 border border-neutral-200 rounded-xl px-3 py-2.5 flex items-center gap-3 hover:border-blue-200 hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<HardDrive className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{result.name}</div>
|
||||
<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.sizeText}
|
||||
</div>
|
||||
<div className="w-8 h-8 rounded-lg bg-white border border-neutral-200 flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
{result.isDir
|
||||
? <Folder className="w-4 h-4 text-amber-500" />
|
||||
: <File className="w-4 h-4 text-blue-500" />
|
||||
}
|
||||
</div>
|
||||
<div className="relative ml-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm font-medium text-neutral-800 truncate">{result.name}</span>
|
||||
<TypeBadge type={result.type} isDir={result.isDir} />
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400 truncate mt-0.5">{result.path}</p>
|
||||
</div>
|
||||
{!result.isDir && (
|
||||
<span className="text-xs text-neutral-400 flex-shrink-0">{result.sizeText}</span>
|
||||
)}
|
||||
<div className="relative flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setShowDeviceMenu(showDeviceMenu === index ? null : index)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm whitespace-nowrap"
|
||||
className="px-3 py-1.5 bg-blue-600 text-white rounded-lg text-xs font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Mount
|
||||
</button>
|
||||
{showDeviceMenu === index && (
|
||||
<div className="absolute right-0 top-12 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[150px] z-20">
|
||||
{availableDevices.map((device) => (
|
||||
<div className="absolute right-0 top-9 bg-white rounded-xl shadow-lg border border-neutral-200 py-1 min-w-[140px] z-20">
|
||||
{availableDevices.map(d => (
|
||||
<button
|
||||
key={device.number}
|
||||
onClick={() => handleMount(device.number, result)}
|
||||
key={d.number}
|
||||
onClick={() => handleMount(d.number, result)}
|
||||
className="w-full px-4 py-2 text-left hover:bg-neutral-50 text-sm"
|
||||
>
|
||||
Device #{device.number}
|
||||
Device #{d.number}
|
||||
</button>
|
||||
))}
|
||||
{availableDevices.length === 0 && (
|
||||
<div className="px-4 py-2 text-sm text-neutral-500">No enabled devices</div>
|
||||
<p className="px-4 py-2 text-sm text-neutral-400">No enabled devices</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -290,20 +323,24 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
|
|||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12 text-neutral-500">
|
||||
No results found for "{query}"
|
||||
<div className="py-16 text-center">
|
||||
<Search className="w-10 h-10 mx-auto mb-3 text-neutral-300" />
|
||||
<p className="text-sm text-neutral-500">No results for "{query}"</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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 on the device</div>
|
||||
{dbPhase === 'ready' && (
|
||||
<div className="text-xs mt-2 text-green-600">Database loaded</div>
|
||||
)}
|
||||
{/* Empty state */}
|
||||
{!busy && !hasSearched && (
|
||||
<div className="py-16 text-center px-6">
|
||||
<Search className="w-10 h-10 mx-auto mb-3 text-neutral-300" />
|
||||
<p className="text-sm font-medium text-neutral-600 mb-1">Search your device</p>
|
||||
<p className="text-xs text-neutral-400">
|
||||
{dbPhase === 'idle'
|
||||
? 'The locate database will be downloaded on first search, or tap the scan icon to rebuild it.'
|
||||
: 'Type a filename or path to search the index.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
||||
import { getWebDAVBaseUrl, basename } from './webdav';
|
||||
import { getWebDAVBaseUrl, basename, listDirectory, putFileContents, deletePath } from './webdav';
|
||||
|
||||
const LOCATE_PATH = '/sd/.locate';
|
||||
|
||||
|
|
@ -76,6 +76,87 @@ export async function openLocateDb(onProgress?: (bytes: number) => void): Promis
|
|||
_db = db;
|
||||
}
|
||||
|
||||
export type ScanPhase = 'scanning' | 'building' | 'saving';
|
||||
|
||||
/**
|
||||
* Recursively scan /sd, build a fresh SQLite locate database in memory,
|
||||
* and upload it to /sd/.locate via WebDAV PUT.
|
||||
*
|
||||
* onProgress(phase, value):
|
||||
* scanning → bytes of PROPFIND XML received so far
|
||||
* building → number of entries inserted so far
|
||||
* saving → bytes of the serialized DB written to the server
|
||||
*/
|
||||
export async function buildLocateDb(
|
||||
onProgress?: (phase: ScanPhase, value: number) => void,
|
||||
): Promise<{ count: number; bytes: number }> {
|
||||
const sqlite3 = await getSqlite3();
|
||||
|
||||
// ── 1. Recursive PROPFIND on /sd ────────────────────────────────────────
|
||||
const entries = await listDirectory(
|
||||
'/sd', true,
|
||||
bytes => onProgress?.('scanning', bytes),
|
||||
);
|
||||
|
||||
// ── 2. Build in-memory DB ───────────────────────────────────────────────
|
||||
const db = new sqlite3.oo1.DB(':memory:', 'ct');
|
||||
db.exec(
|
||||
'CREATE TABLE files (' +
|
||||
' path TEXT PRIMARY KEY,' +
|
||||
' size INTEGER NOT NULL DEFAULT 0,' +
|
||||
' mtime INTEGER NOT NULL DEFAULT 0,' +
|
||||
' is_dir INTEGER NOT NULL DEFAULT 0' +
|
||||
');' +
|
||||
'CREATE INDEX IF NOT EXISTS idx_path ON files(path);',
|
||||
);
|
||||
|
||||
db.exec('BEGIN');
|
||||
const stmt = db.prepare('INSERT OR REPLACE INTO files VALUES (?,?,?,?)');
|
||||
try {
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const e = entries[i];
|
||||
stmt.bind([
|
||||
e.path,
|
||||
e.size,
|
||||
e.lastModified ? Math.floor(e.lastModified.getTime() / 1000) : 0,
|
||||
e.type === 'folder' ? 1 : 0,
|
||||
]).stepReset();
|
||||
if (i % 250 === 0) onProgress?.('building', i);
|
||||
}
|
||||
} finally {
|
||||
stmt.finalize();
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
onProgress?.('building', entries.length);
|
||||
|
||||
// ── 3. Serialize to bytes ────────────────────────────────────────────────
|
||||
const pSaved = sqlite3.wasm.pstack.pointer;
|
||||
let dbBytes: Uint8Array;
|
||||
try {
|
||||
const pSize = sqlite3.wasm.pstack.alloc(8);
|
||||
const pData = sqlite3.capi.sqlite3_serialize(db.pointer, 'main', pSize, 0);
|
||||
if (!pData) throw new Error('sqlite3_serialize returned NULL');
|
||||
const rawSize = sqlite3.wasm.peek(pSize, 'i64');
|
||||
const size = Number(typeof rawSize === 'bigint' ? rawSize : rawSize);
|
||||
dbBytes = sqlite3.wasm.heap8u().slice(pData, pData + size);
|
||||
sqlite3.capi.sqlite3_free(pData);
|
||||
} finally {
|
||||
sqlite3.wasm.pstack.restore(pSaved);
|
||||
db.close();
|
||||
}
|
||||
|
||||
// ── 4. Delete existing + upload ─────────────────────────────────────────
|
||||
onProgress?.('saving', 0);
|
||||
await deletePath(LOCATE_PATH).catch(() => {});
|
||||
await putFileContents(LOCATE_PATH, dbBytes);
|
||||
onProgress?.('saving', dbBytes.length);
|
||||
|
||||
// Invalidate cached DB so the next search reloads the fresh file.
|
||||
resetLocateDb();
|
||||
|
||||
return { count: entries.length, bytes: dbBytes.length };
|
||||
}
|
||||
|
||||
export interface LocateEntry {
|
||||
path: string;
|
||||
name: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user