/* Application bootstrap for the static, client-side metadata browser. - Downloads a zipped SQLite DB (db.zip) with progress and unzips it in-memory - Caches the uncompressed DB bytes in IndexedDB for reuse on next load - Loads sql.js (WASM) and opens the database from the cached bytes - Hides the loader and shows a stub UI once ready Assumptions: - A ZIP archive is hosted at `/db.zip` containing a single `.sqlite` file. - We use `fflate` (UMD) from a CDN to unzip after download (keeps implementation minimal). - We use `sql.js` from a CDN; the WASM is located via `locateFile` (see initSql()). - We store raw DB bytes in IndexedDB (localStorage is too small for large DBs). */ (function () { const DB_ZIP_URL = '/db.zip'; // Adjust if you relocate the archive (e.g., /assets/db.zip) const IDB_NAME = 'mp3com-meta-browser'; const IDB_STORE = 'files'; const IDB_KEY = 'mp3com-db-bytes-v1'; // bump if format changes // UI elements const $loader = document.getElementById('loader'); const $loaderStep = document.getElementById('loader-step'); const $loaderDetail = document.getElementById('loader-detail'); const $loaderProgress = document.getElementById('loader-progress'); const $app = document.getElementById('app'); const $q = document.getElementById('q'); const $results = document.getElementById('results'); function setStep(text) { $loaderStep.textContent = text; } function setDetail(text) { $loaderDetail.textContent = text; } function setProgress(value, max) { if (typeof max === 'number' && max > 0) { $loaderProgress.max = max; $loaderProgress.value = value; const pct = Math.floor((value / max) * 100); $loaderProgress.textContent = pct + '%'; } else { // Unknown total; use a simple textual fallback $loaderProgress.removeAttribute('max'); $loaderProgress.value = 0; $loaderProgress.textContent = '…'; } } // --- IndexedDB helpers (minimal, no external deps) --- function idbOpen() { return new Promise((resolve, reject) => { const req = indexedDB.open(IDB_NAME, 1); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(IDB_STORE)) db.createObjectStore(IDB_STORE); }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async function idbGet(key) { const db = await idbOpen(); return new Promise((resolve, reject) => { const tx = db.transaction(IDB_STORE, 'readonly'); const req = tx.objectStore(IDB_STORE).get(key); req.onsuccess = () => resolve(req.result ?? null); req.onerror = () => reject(req.error); }); } async function idbSet(key, value) { const db = await idbOpen(); return new Promise((resolve, reject) => { const tx = db.transaction(IDB_STORE, 'readwrite'); const req = tx.objectStore(IDB_STORE).put(value, key); req.onsuccess = () => resolve(); req.onerror = () => reject(req.error); }); } // --- Download and unzip db.zip --- async function fetchZipWithProgress(url) { setStep('Downloading database…'); setDetail('Starting download'); const resp = await fetch(url, { cache: 'force-cache' }); if (!resp.ok || !resp.body) throw new Error('Failed to fetch DB archive'); const total = parseInt(resp.headers.get('content-length') || '0', 10) || 0; const reader = resp.body.getReader(); const chunks = []; let received = 0; setProgress(0, total || undefined); while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); received += value.byteLength; setProgress(received, total || undefined); if (total) setDetail(`${((received / total) * 100).toFixed(1)}% • ${formatBytes(received)} / ${formatBytes(total)}`); else setDetail(`${formatBytes(received)} downloaded`); } const zipBytes = concatUint8(chunks, received); return zipBytes; } function concatUint8(chunks, totalLen) { const out = new Uint8Array(totalLen); let offset = 0; for (const c of chunks) { out.set(c, offset); offset += c.byteLength; } return out; } function formatBytes(bytes) { const units = ['B', 'KB', 'MB', 'GB']; let i = 0; let val = bytes; while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; } return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; } async function unzipSqlite(zipBytes) { setStep('Unpacking database…'); setDetail('Decompressing ZIP'); // fflate is provided globally as window.fflate const { unzipSync } = window.fflate || {}; if (!unzipSync) throw new Error('Unzip library not loaded'); const files = unzipSync(zipBytes); // returns { [name]: Uint8Array } // Heuristic: pick the first .sqlite (or first entry if none matches) const names = Object.keys(files); if (!names.length) throw new Error('Empty ZIP archive'); let choice = names.find((n) => /\.sqlite$/i.test(n)) || names[0]; const dbBytes = files[choice]; setDetail(`Unpacked ${choice} • ${formatBytes(dbBytes.byteLength)}`); setProgress(100, 100); return dbBytes; } // --- SQL.js init --- async function initSql() { // sql.js (UMD) exposes initSqlJs globally; WASM resolved via locateFile if (typeof initSqlJs !== 'function') throw new Error('sql.js not loaded'); setStep('Initializing SQLite…'); setDetail('Loading WASM'); const SQL = await initSqlJs({ // Pin to the same CDN base as the script tag above locateFile: (file) => `https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.2/${file}`, }); return SQL; } // --- Main bootstrap --- (async function main() { try { // 1) Try cache first setStep('Checking cache…'); setDetail('Looking for cached database'); let dbBytes = await idbGet(IDB_KEY); if (dbBytes) { setStep('Using cached database'); setDetail(`Found ${formatBytes(dbBytes.byteLength)} in IndexedDB`); setProgress(100, 100); } else { // 2) Fetch and unzip, then cache const zipBytes = await fetchZipWithProgress(DB_ZIP_URL); dbBytes = await unzipSqlite(zipBytes); setDetail('Caching database for future loads'); await idbSet(IDB_KEY, dbBytes); } // 3) Initialize SQL.js and open the DB const SQL = await initSql(); setDetail('Opening database'); const db = new SQL.Database(dbBytes); // Very small sanity check (does not assume tables exist yet). If desired, we could // run `SELECT name FROM sqlite_master LIMIT 1` here. Keep minimal per project goals. // 4) Reveal app UI $loader.hidden = true; $app.hidden = false; $q.disabled = false; $q.removeAttribute('aria-disabled'); $results.innerHTML = '
Database initialized. Stub UI ready.
'; // 5) Wire a minimal, no-op search stub (ready for future expansion) document.getElementById('search-form').addEventListener('submit', (e) => e.preventDefault()); $q.addEventListener('input', () => { // Placeholder behavior; later this will issue FTS queries via db.prepare / db.exec const q = $q.value.trim(); if (!q) { $results.innerHTML = 'Type to search…
'; return; } $results.innerHTML = `Search stub — ready for query: ${escapeHtml(q)}