meatloaf-config/src/app/locate-db.ts

290 lines
10 KiB
TypeScript

import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
import wasmUrl from '@sqlite.org/sqlite-wasm/sqlite3.wasm?url';
import { getWebDAVBaseUrl, basename, listDirectory, putFileContents, deletePath } from './webdav';
const LOCATE_PATH = '/sd/.locate';
// Vite rewrites this `?url` import to the hashed asset path
// (e.g. /assets/sqlite3-BVKGSWc-.wasm) and respects the configured base path.
const WASM_URL: string = wasmUrl;
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<Uint8Array> {
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<any> | null = null;
function getSqlite3(
onProgress?: (p: { received: number; total: number | null }) => void,
): Promise<any> {
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;
_loadState = { phase: 'idle' };
for (const cb of _loadSubscribers) cb(_loadState);
}
// ── Multi-subscriber progress for the load pipeline ────────────────────────
// The SearchPane kicks off openLocateDb() on mount (so the user can see the
// transfer happen on the panel itself, not buried inside the search action).
// SearchLocal may also call openLocateDb() when the user types a query, and
// it needs to receive the same progress events. This module-level pub/sub
// fan-outs events to every active subscriber; late subscribers get an
// immediate "done" notification so they can skip their own progress UI.
type LoadState =
| { phase: 'idle' }
| { phase: 'engine' | 'database'; received: number; total: number | null }
| { phase: 'ready' }
| { phase: 'error'; message: string };
let _loadState: LoadState = { phase: 'idle' };
const _loadSubscribers = new Set<(s: LoadState) => void>();
function _setLoadState(s: LoadState) {
_loadState = s;
for (const cb of _loadSubscribers) cb(s);
}
/**
* Subscribe to the in-flight (or already-completed) locate-database load.
* The callback fires immediately with the current state, then on every
* change. Returns an unsubscribe function. Multiple subscribers (e.g. the
* SearchPane header and the SearchLocal panel) can observe the same load.
*/
export function subscribeLoadProgress(cb: (s: LoadState) => void): () => void {
_loadSubscribers.add(cb);
cb(_loadState);
return () => { _loadSubscribers.delete(cb); };
}
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<void> {
if (_db) return;
const sqlite3 = await getSqlite3(p => {
onProgress?.({ kind: 'engine', received: p.received, total: p.total });
_setLoadState({ phase: '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 });
_setLoadState({ phase: '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;
_setLoadState({ phase: 'ready' });
}
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;
}