diff --git a/static/app.js b/static/app.js index 0dfb7c5..b70ad34 100644 --- a/static/app.js +++ b/static/app.js @@ -1,6 +1,8 @@ import { bootstrapPluginsAndUi, + checkPluginUpdates, loadPlugins, + updatePlugin, } from './js/plugin-loader.js'; import { _autoMatchViz, @@ -33,6 +35,10 @@ import { parseRawTuningOffsets, songTuningContext, } from './js/tuning-display.js'; +import { + exportSettings, + importSettings, +} from './js/settings-io.js'; // Demo analytics — real impl set by demo.js; no-op in normal builds window.feedBackDemoTrack = window.feedBackDemoTrack ?? null; @@ -3101,153 +3107,6 @@ async function _postSetting(key, value) { } } -// ── Settings export / import (feedBack#113) ───────────────────────────────── -// -// Bundles server config + every localStorage key + opted-in plugin server -// files into a single JSON file. -// -// Apply semantics — phased, NOT all-or-nothing across the two stores: -// 1. Server first (/api/settings/import). Phase-1 validation guards -// the whole bundle; phase-2 disk commit is per-file but ordered -// so a mid-apply failure surfaces a `partial` field. A server -// failure short-circuits before any localStorage write, so the -// browser side stays untouched on validation refusals. -// 2. localStorage second, only after the server returns ok. Applied -// as a MERGE (no clear): bundled keys overwrite, locally-present -// keys absent from the bundle are preserved (so a plugin -// installed after the export keeps its first-run defaults). -// A localStorage exception here (quota / private mode) is -// surfaced verbatim — server state is already committed and we -// don't pretend the import was clean. -// -// In short: the server side is atomic in phase 1 and surface-partial in -// phase 2; the localStorage side is best-effort merge after server -// success. Failures are reported, never silenced. - -async function exportSettings() { - const status = document.getElementById('backup-status'); - status.textContent = 'Exporting...'; - try { - const resp = await fetch('/api/settings/export'); - if (!resp.ok) { - status.textContent = `Export failed (HTTP ${resp.status})`; - return; - } - const bundle = await resp.json(); - // Layer in the browser's localStorage. Use the standard Storage - // iteration API (length + key(i)) rather than Object.keys — - // Object.keys on a Storage instance is not deterministic across - // browsers and can both miss entries and include non-entry - // properties depending on the implementation. Keys are preserved - // verbatim as strings; that's how localStorage stores them, and - // round-trip fidelity matters more than re-typing values that - // were never typed in the first place. - const localStorageData = {}; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key === null) continue; - const value = localStorage.getItem(key); - if (value !== null) localStorageData[key] = value; - } - bundle.local_storage = localStorageData; - - // Trigger download via blob + temporary . We honor the - // server's Content-Disposition filename when present, otherwise - // fall back to a date-stamped default. - let filename = 'feedBack-settings.json'; - const disposition = resp.headers.get('Content-Disposition'); - if (disposition) { - const match = /filename="([^"]+)"/.exec(disposition); - if (match) filename = match[1]; - } - const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - status.textContent = `Exported ${filename}`; - } catch (e) { - status.textContent = `Export failed: ${e.message}`; - } -} - -async function importSettings(file) { - if (!file) return; - const status = document.getElementById('backup-status'); - if (!confirm('Import will overwrite settings present in the bundle (server config, browser preferences, and opted-in plugin data) and reload the page. Settings not in the bundle (e.g. from plugins installed after the export) are preserved. Continue?')) { - status.textContent = 'Import cancelled'; - return; - } - let bundle; - try { - bundle = JSON.parse(await file.text()); - } catch (e) { - status.textContent = `Import failed: not valid JSON (${e.message})`; - return; - } - - status.textContent = 'Importing...'; - let resp, data; - try { - resp = await fetch('/api/settings/import', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(bundle), - }); - data = await resp.json(); - } catch (e) { - status.textContent = `Import failed: ${e.message}`; - return; - } - // Two failure shapes to surface: our own validation handler - // returns `{ok: false, error: "..."}`, but if the body fails - // FastAPI's request-level validation (e.g. top-level value is - // an array, not an object), the response is the framework's - // `{detail: ...}` shape with no `ok` key. `resp.ok` distinguishes - // both from success without depending on which path produced - // the failure. - if (!resp.ok || data.ok === false) { - let msg = data.error; - if (!msg && data.detail) { - msg = typeof data.detail === 'string' - ? data.detail - : JSON.stringify(data.detail); - } - status.textContent = `Import failed: ${msg || `HTTP ${resp.status}`}`; - return; - } - - // Server applied successfully. Now apply the localStorage portion as - // a MERGE (not clear+restore): keys in the bundle overwrite, keys - // present locally but absent from the bundle are preserved. This - // matters when a plugin was installed *after* the export — wiping - // its localStorage would erase first-run defaults the plugin set on - // load, leaving it in a worse state than before the import. The - // tradeoff is that orphan keys from removed plugins or renamed key - // schemes also linger; cleaning those up is the user's job. - const ls = bundle.local_storage; - if (ls && typeof ls === 'object') { - try { - for (const [key, value] of Object.entries(ls)) { - if (typeof value === 'string') localStorage.setItem(key, value); - } - } catch (e) { - // Quota exceeded / private mode etc. Server side already - // committed, so we surface the partial state rather than - // pretending it succeeded. - status.textContent = `Server applied, but localStorage write failed: ${e.message}`; - return; - } - } - - const warnings = (data.warnings || []).join('; '); - status.textContent = warnings ? `Imported with warnings: ${warnings}. Reloading...` : 'Imported. Reloading...'; - setTimeout(() => location.reload(), 800); -} async function uploadSongs(fileList) { @@ -3497,58 +3356,6 @@ async function fullRescanLibrary() { }, 1000); } -// ── Plugin Updates ─────────────────────────────────────────────────────── -async function checkPluginUpdates() { - const btn = document.getElementById('btn-check-updates'); - const status = document.getElementById('updates-status'); - const list = document.getElementById('plugin-updates-list'); - btn.disabled = true; - btn.textContent = 'Checking...'; - status.textContent = ''; - list.innerHTML = ''; - try { - const resp = await fetch('/api/plugins/updates'); - const data = await resp.json(); - const updates = data.updates || {}; - const keys = Object.keys(updates); - if (keys.length === 0) { - status.textContent = 'All plugins are up to date.'; - } else { - status.textContent = `${keys.length} update${keys.length > 1 ? 's' : ''} available`; - for (const id of keys) { - const u = updates[id]; - const row = document.createElement('div'); - row.className = 'flex items-center gap-3 bg-dark-700 rounded-lg px-4 py-2'; - row.innerHTML = ` - ${u.name} (${u.behind} commit${u.behind > 1 ? 's' : ''} behind — ${u.local} → ${u.remote}) - `; - list.appendChild(row); - } - } - } catch (e) { - status.textContent = 'Failed to check for updates.'; - } - btn.disabled = false; - btn.textContent = 'Check for Updates'; -} - -async function updatePlugin(pluginId, btn) { - btn.disabled = true; - btn.textContent = 'Updating...'; - try { - const resp = await fetch(`/api/plugins/${pluginId}/update`, { method: 'POST' }); - const data = await resp.json(); - if (data.ok) { - btn.textContent = 'Updated — restart to apply'; - btn.className = 'bg-green-900/30 text-green-400 px-3 py-1 rounded-lg text-xs'; - } else { - btn.textContent = 'Failed'; - btn.title = data.error || ''; - } - } catch (e) { - btn.textContent = 'Error'; - } -} // ── Plugin functions loaded dynamically from plugin screen.js files ────── // (searchCF, installCF, loginCF, searchUG, buildFromUG, etc.) diff --git a/static/js/plugin-loader.js b/static/js/plugin-loader.js index be4af3b..289787b 100644 --- a/static/js/plugin-loader.js +++ b/static/js/plugin-loader.js @@ -802,3 +802,62 @@ export async function bootstrapPluginsAndUi() { _streamPluginStartup(); return plugins; } + + +// ── Plugin updates ────────────────────────────────────────────────────── +// The Settings-screen "Check for updates" / "Update" buttons. Carved out of +// app.js (R3a) into the loader rather than a module of their own: this is plugin +// MANAGEMENT, it belongs with the code that loads them. Both are inline handlers, +// so app.js re-exposes them on window. + +export async function checkPluginUpdates() { + const btn = document.getElementById('btn-check-updates'); + const status = document.getElementById('updates-status'); + const list = document.getElementById('plugin-updates-list'); + btn.disabled = true; + btn.textContent = 'Checking...'; + status.textContent = ''; + list.innerHTML = ''; + try { + const resp = await fetch('/api/plugins/updates'); + const data = await resp.json(); + const updates = data.updates || {}; + const keys = Object.keys(updates); + if (keys.length === 0) { + status.textContent = 'All plugins are up to date.'; + } else { + status.textContent = `${keys.length} update${keys.length > 1 ? 's' : ''} available`; + for (const id of keys) { + const u = updates[id]; + const row = document.createElement('div'); + row.className = 'flex items-center gap-3 bg-dark-700 rounded-lg px-4 py-2'; + row.innerHTML = ` + ${u.name} (${u.behind} commit${u.behind > 1 ? 's' : ''} behind — ${u.local} → ${u.remote}) + `; + list.appendChild(row); + } + } + } catch (e) { + status.textContent = 'Failed to check for updates.'; + } + btn.disabled = false; + btn.textContent = 'Check for Updates'; +} + +export async function updatePlugin(pluginId, btn) { + btn.disabled = true; + btn.textContent = 'Updating...'; + try { + const resp = await fetch(`/api/plugins/${pluginId}/update`, { method: 'POST' }); + const data = await resp.json(); + if (data.ok) { + btn.textContent = 'Updated — restart to apply'; + btn.className = 'bg-green-900/30 text-green-400 px-3 py-1 rounded-lg text-xs'; + } else { + btn.textContent = 'Failed'; + btn.title = data.error || ''; + } + } catch (e) { + btn.textContent = 'Error'; + } +} diff --git a/static/js/settings-io.js b/static/js/settings-io.js new file mode 100644 index 0000000..e04fc83 --- /dev/null +++ b/static/js/settings-io.js @@ -0,0 +1,155 @@ +// Settings backup — the export / import bundle. +// +// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing. +// +// Two entry points, both inline handlers on the Settings screen, so app.js keeps +// re-exposing them on window. The import is two-phase (server first, atomic; then +// a best-effort localStorage merge) — the rationale comment below is the contract +// and moved with the code. + +// +// Bundles server config + every localStorage key + opted-in plugin server +// files into a single JSON file. +// +// Apply semantics — phased, NOT all-or-nothing across the two stores: +// 1. Server first (/api/settings/import). Phase-1 validation guards +// the whole bundle; phase-2 disk commit is per-file but ordered +// so a mid-apply failure surfaces a `partial` field. A server +// failure short-circuits before any localStorage write, so the +// browser side stays untouched on validation refusals. +// 2. localStorage second, only after the server returns ok. Applied +// as a MERGE (no clear): bundled keys overwrite, locally-present +// keys absent from the bundle are preserved (so a plugin +// installed after the export keeps its first-run defaults). +// A localStorage exception here (quota / private mode) is +// surfaced verbatim — server state is already committed and we +// don't pretend the import was clean. +// +// In short: the server side is atomic in phase 1 and surface-partial in +// phase 2; the localStorage side is best-effort merge after server +// success. Failures are reported, never silenced. + +export async function exportSettings() { + const status = document.getElementById('backup-status'); + status.textContent = 'Exporting...'; + try { + const resp = await fetch('/api/settings/export'); + if (!resp.ok) { + status.textContent = `Export failed (HTTP ${resp.status})`; + return; + } + const bundle = await resp.json(); + // Layer in the browser's localStorage. Use the standard Storage + // iteration API (length + key(i)) rather than Object.keys — + // Object.keys on a Storage instance is not deterministic across + // browsers and can both miss entries and include non-entry + // properties depending on the implementation. Keys are preserved + // verbatim as strings; that's how localStorage stores them, and + // round-trip fidelity matters more than re-typing values that + // were never typed in the first place. + const localStorageData = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key === null) continue; + const value = localStorage.getItem(key); + if (value !== null) localStorageData[key] = value; + } + bundle.local_storage = localStorageData; + + // Trigger download via blob + temporary . We honor the + // server's Content-Disposition filename when present, otherwise + // fall back to a date-stamped default. + let filename = 'feedBack-settings.json'; + const disposition = resp.headers.get('Content-Disposition'); + if (disposition) { + const match = /filename="([^"]+)"/.exec(disposition); + if (match) filename = match[1]; + } + const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + status.textContent = `Exported ${filename}`; + } catch (e) { + status.textContent = `Export failed: ${e.message}`; + } +} + +export async function importSettings(file) { + if (!file) return; + const status = document.getElementById('backup-status'); + if (!confirm('Import will overwrite settings present in the bundle (server config, browser preferences, and opted-in plugin data) and reload the page. Settings not in the bundle (e.g. from plugins installed after the export) are preserved. Continue?')) { + status.textContent = 'Import cancelled'; + return; + } + let bundle; + try { + bundle = JSON.parse(await file.text()); + } catch (e) { + status.textContent = `Import failed: not valid JSON (${e.message})`; + return; + } + + status.textContent = 'Importing...'; + let resp, data; + try { + resp = await fetch('/api/settings/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(bundle), + }); + data = await resp.json(); + } catch (e) { + status.textContent = `Import failed: ${e.message}`; + return; + } + // Two failure shapes to surface: our own validation handler + // returns `{ok: false, error: "..."}`, but if the body fails + // FastAPI's request-level validation (e.g. top-level value is + // an array, not an object), the response is the framework's + // `{detail: ...}` shape with no `ok` key. `resp.ok` distinguishes + // both from success without depending on which path produced + // the failure. + if (!resp.ok || data.ok === false) { + let msg = data.error; + if (!msg && data.detail) { + msg = typeof data.detail === 'string' + ? data.detail + : JSON.stringify(data.detail); + } + status.textContent = `Import failed: ${msg || `HTTP ${resp.status}`}`; + return; + } + + // Server applied successfully. Now apply the localStorage portion as + // a MERGE (not clear+restore): keys in the bundle overwrite, keys + // present locally but absent from the bundle are preserved. This + // matters when a plugin was installed *after* the export — wiping + // its localStorage would erase first-run defaults the plugin set on + // load, leaving it in a worse state than before the import. The + // tradeoff is that orphan keys from removed plugins or renamed key + // schemes also linger; cleaning those up is the user's job. + const ls = bundle.local_storage; + if (ls && typeof ls === 'object') { + try { + for (const [key, value] of Object.entries(ls)) { + if (typeof value === 'string') localStorage.setItem(key, value); + } + } catch (e) { + // Quota exceeded / private mode etc. Server side already + // committed, so we surface the partial state rather than + // pretending it succeeded. + status.textContent = `Server applied, but localStorage write failed: ${e.message}`; + return; + } + } + + const warnings = (data.warnings || []).join('; '); + status.textContent = warnings ? `Imported with warnings: ${warnings}. Reloading...` : 'Imported. Reloading...'; + setTimeout(() => location.reload(), 800); +}