import sqlite3InitModule from '@sqlite.org/sqlite-wasm'; import { getWebDAVBaseUrl, basename, listDirectory, putFileContents, deletePath } from './webdav'; const LOCATE_PATH = '/sd/.locate'; // Resolved against import.meta.url so it works under any Vite base path // (/, /config/, etc.) the same way the sqlite-wasm library resolves it. const WASM_URL = new URL('sqlite3.wasm', import.meta.url).toString(); function parseContentLength(header: string | null): number | null { if (!header) return null; const n = Number(header); return Number.isFinite(n) && n > 0 ? n : null; } // Fetch a URL and report progress as { received, total } chunks arrive. // Returns the full byte array. Streams the body so progress is real-time // (not just a single notification when the whole thing is buffered). async function fetchWithProgress( url: string, onProgress?: (p: { received: number; total: number | null }) => void, ): Promise { const response = await fetch(url); if (!response.ok) throw new Error(`Cannot fetch ${url}: ${response.status} ${response.statusText}`); const total = parseContentLength(response.headers.get('content-length')); if (!response.body) { const buf = new Uint8Array(await response.arrayBuffer()); onProgress?.({ received: buf.byteLength, total: total ?? buf.byteLength }); return buf; } const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let received = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); received += value.byteLength; onProgress?.({ received, total }); } const combined = new Uint8Array(received); let offset = 0; for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; } return combined; } // Memoize the module init — loading the WASM binary is expensive. // We pre-fetch the WASM in the main thread (with progress reporting) // and hand the bytes to Emscripten as Module.wasmBinary so it skips // its own internal fetch. The sqlite-wasm worker still loads its own // copy on first use, but the main-thread load (the one users see in // the network panel as a 400 KB download) is gone. let _sqlite3Promise: Promise | null = null; function getSqlite3( onProgress?: (p: { received: number; total: number | null }) => void, ): Promise { if (_sqlite3Promise) return _sqlite3Promise; _sqlite3Promise = (async () => { const wasmBytes = await fetchWithProgress(WASM_URL, onProgress); return sqlite3InitModule({ print: () => {}, printErr: () => {}, wasmBinary: wasmBytes, }); })(); return _sqlite3Promise; } let _db: any | null = null; export function isLocateDbLoaded(): boolean { return _db !== null; } export function resetLocateDb(): void { try { _db?.close(); } catch { /* ignore */ } _db = null; } export type DbProgress = | { kind: 'engine'; received: number; total: number | null } | { kind: 'database'; received: number; total: number | null }; /** * Fetch /sd/.locate and open it as an in-memory SQLite database. On the first * call this also loads the SQLite WASM engine (~400 KB), so onProgress fires * twice: first for the engine download, then for the locate database itself. * Calling again when already loaded is a no-op unless you call * resetLocateDb() first. */ export async function openLocateDb( onProgress?: (p: DbProgress) => void, ): Promise { if (_db) return; const sqlite3 = await getSqlite3( p => onProgress?.({ kind: 'engine', received: p.received, total: p.total }), ); const url = getWebDAVBaseUrl() + LOCATE_PATH; const bytes = await fetchWithProgress( url, p => onProgress?.({ kind: 'database', received: p.received, total: p.total }), ); // Allocate the bytes in WASM heap, then deserialize into a fresh in-memory DB. const p = sqlite3.wasm.allocFromTypedArray(bytes); const db = new sqlite3.oo1.DB(':memory:', 'ct'); const rc = sqlite3.capi.sqlite3_deserialize( db.pointer, 'main', p, bytes.length, bytes.length, 1 | 2, // SQLITE_DESERIALIZE_FREEONCLOSE | SQLITE_DESERIALIZE_RESIZEABLE ); if (rc !== 0) { db.close(); throw new Error(`sqlite3_deserialize failed (code ${rc})`); } _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; size: number; mtime: number; is_dir: boolean; } /** * Convert a user wildcard query (* and ?) to a SQLite LIKE pattern. * Without wildcards, wraps in % for substring match. * SQL LIKE specials (% _ \) in the literal parts are escaped. */ function toSqlLike(query: string): string { const hasWildcard = /[*?]/.test(query); // Escape SQL LIKE special chars that the user did NOT type as wildcards const escaped = query.replace(/[\\%_]/g, '\\$&'); if (!hasWildcard) return `%${escaped}%`; return escaped.replace(/\*/g, '%').replace(/\?/g, '_'); } /** * Run a case-insensitive search against the loaded locate database. * Supports * (any chars) and ? (single char) wildcards. * Without wildcards, performs a substring match. * openLocateDb() must have been called (and resolved) before calling this. */ export function searchLocate(query: string, limit = 500): LocateEntry[] { if (!_db) throw new Error('Locate database is not loaded'); const needle = toSqlLike(query); const rows: LocateEntry[] = []; _db.exec({ sql: "SELECT path, size, mtime, is_dir FROM files WHERE path LIKE ? ESCAPE '\\' LIMIT ?", bind: [needle, limit], rowMode: 'array', callback: (row: any[]) => { rows.push({ path: row[0] as string, name: basename(row[0] as string) || (row[0] as string), size: row[1] as number, mtime: row[2] as number, is_dir: (row[3] as number) !== 0, }); }, }); return rows; }