feat: add Bulma loader and client-side DB bootstrap (fflate + sql.js); download/unzip/cache DB and show stub UI

This commit is contained in:
Jordan Wages 2025-09-16 16:51:17 -05:00
commit 669e73f065
5 changed files with 505 additions and 0 deletions

224
script.js Normal file
View file

@ -0,0 +1,224 @@
/*
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 = '<p class="has-text-success">Database initialized. Stub UI ready.</p>';
// 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 = '<p class="has-text-grey">Type to search…</p>';
return;
}
$results.innerHTML = `<p class="has-text-grey">Search stub — ready for query: <code>${escapeHtml(q)}</code></p>`;
});
// Expose db for future debugging in console (non-production convenience)
window.__db = db;
} catch (err) {
console.error(err);
setStep('Initialization failed');
setDetail(String(err && err.message ? err.message : err));
$loaderProgress.classList.remove('is-primary');
$loaderProgress.classList.add('is-danger');
}
})();
function escapeHtml(s) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
})();