mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 20:31:21 +00:00
refactor(app): carve settings backup + plugin updates out of app.js (R3a) (#885)
Two leaves, one PR. app.js 9,651 → 9,457.
static/js/settings-io.js (155) — exportSettings + importSettings, the Settings
backup bundle. Imports nothing. The two-phase rationale comment (server first and
atomic; then a best-effort localStorage merge) is the contract and moved with the
code.
plugin-updates → INTO static/js/plugin-loader.js, not a module of its own.
checkPluginUpdates + updatePlugin are plugin MANAGEMENT; they belong with the code
that loads plugins. A new file for 50 lines would have been a file for its own
sake.
All four are inline handlers on the Settings screen and already in app.js's window
contract, so app.js re-exposes the imported bindings unchanged.
VERIFIED BY DRIVING BOTH FLOWS. A/B against origin/main in two browsers:
* checkPluginUpdates() -> hits the API and settles the button back to
"Check for Updates" — IDENTICAL
* exportSettings() -> POSTs /api/settings/export and writes
"Exported feedBack-settings…" to #backup-status — IDENTICAL (fetch intercepted
so the assertion is on the real call, not a stub)
* all four resolve on window — IDENTICAL
* zero console/page errors either side
Zero harnesses broke. pytest 2396, node 1038/1038, ESLint 0, tailwind clean, Codex 0.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d47883c5e5
commit
b5dd585d25
205
static/app.js
205
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 <a download>. 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 = `
|
||||
<span class="text-sm text-gray-300 flex-1">${u.name} <span class="text-xs text-gray-500">(${u.behind} commit${u.behind > 1 ? 's' : ''} behind — ${u.local} → ${u.remote})</span></span>
|
||||
<button onclick="updatePlugin('${id}', this)" class="bg-accent/20 hover:bg-accent/30 text-accent-light px-3 py-1 rounded-lg text-xs transition">Update</button>`;
|
||||
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.)
|
||||
|
||||
@ -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 = `
|
||||
<span class="text-sm text-gray-300 flex-1">${u.name} <span class="text-xs text-gray-500">(${u.behind} commit${u.behind > 1 ? 's' : ''} behind — ${u.local} → ${u.remote})</span></span>
|
||||
<button onclick="updatePlugin('${id}', this)" class="bg-accent/20 hover:bg-accent/30 text-accent-light px-3 py-1 rounded-lg text-xs transition">Update</button>`;
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
155
static/js/settings-io.js
Normal file
155
static/js/settings-io.js
Normal file
@ -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 <a download>. 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);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user