251 lines
8.8 KiB
TypeScript
251 lines
8.8 KiB
TypeScript
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<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;
|
|
}
|
|
|
|
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 }),
|
|
);
|
|
|
|
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;
|
|
}
|