mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 17:28:32 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fbe650ea6 | ||
|
|
d47883c5e5 |
+15
-395
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
bootstrapPluginsAndUi,
|
||||
checkPluginUpdates,
|
||||
loadPlugins,
|
||||
updatePlugin,
|
||||
} from './js/plugin-loader.js';
|
||||
import {
|
||||
_autoMatchViz,
|
||||
@@ -24,6 +26,19 @@ import {
|
||||
hwcInitSettingsUI,
|
||||
initHighwayColors,
|
||||
} from './js/highway-colors.js';
|
||||
import {
|
||||
displayTuningName,
|
||||
displayTuningTargetDetails,
|
||||
displayTuningTargets,
|
||||
effectiveStringCount,
|
||||
isBassArrangement,
|
||||
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;
|
||||
@@ -2305,227 +2320,31 @@ function toggleAllFavoriteArtists(expand) {
|
||||
_toggleAllInTree('fav-tree', expand, _FAV_TREE_EXPAND_KEY);
|
||||
}
|
||||
|
||||
// Display-only tuning label helpers — never mutate offsets or affect playback.
|
||||
function _looksLikeRawTuningOffsets(str) {
|
||||
if (!str || typeof str !== 'string') return false;
|
||||
const s = str.trim();
|
||||
if (!s) return false;
|
||||
if (/^-?\d+$/.test(s)) return true;
|
||||
if (/^-?\d+(?: -?\d+)+$/.test(s)) return true;
|
||||
if (/^-?\d+(?:,-?\d+)+$/.test(s)) return true;
|
||||
if (/^-?\d+(-?\d+){2,}$/.test(s)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function _tuningNameFromOffsets(offsets) {
|
||||
if (!offsets || !offsets.length) return '';
|
||||
const standard = {
|
||||
0: 'E Standard', '-1': 'Eb Standard', '-2': 'D Standard',
|
||||
'-3': 'C# Standard', '-4': 'C Standard', '-5': 'B Standard',
|
||||
'-6': 'Bb Standard', '-7': 'A Standard',
|
||||
1: 'F Standard', 2: 'F# Standard',
|
||||
};
|
||||
// Uniform offsets across 4 (bass) / 5 / 6 strings name the same Standard;
|
||||
// a 4-string bass [0,0,0,0] must read "E Standard", not "Custom Tuning".
|
||||
if (offsets.length >= 4 && offsets.every((o) => o === offsets[0])) {
|
||||
const name = standard[offsets[0]];
|
||||
if (name) return name;
|
||||
}
|
||||
if (offsets.length >= 4 && offsets[0] === offsets[1] - 2
|
||||
&& offsets.slice(1).every((o) => o === offsets[1])) {
|
||||
const noteNames = ['E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B', 'C', 'C#', 'D', 'Eb'];
|
||||
return 'Drop ' + noteNames[((offsets[0] % 12) + 12) % 12];
|
||||
}
|
||||
const named = {
|
||||
'-2,0,0,0,0,0': 'Drop D',
|
||||
'-4,-2,-2,-2,-2,-2': 'Drop C',
|
||||
'-2,-2,0,0,0,0': 'Double Drop D',
|
||||
'0,0,0,-1,0,0': 'Open G',
|
||||
'-2,-2,0,0,-2,-2': 'Open D',
|
||||
'-2,0,0,0,-2,0': 'DADGAD',
|
||||
'0,2,2,1,0,0': 'Open E',
|
||||
'-2,0,0,2,3,2': 'Open D (alt)',
|
||||
};
|
||||
if (offsets.length === 6) {
|
||||
const key = offsets.join(',');
|
||||
if (named[key]) return named[key];
|
||||
}
|
||||
return 'Custom Tuning';
|
||||
}
|
||||
|
||||
function displayTuningName(value, offsets) {
|
||||
// Explicit offsets win — always name them.
|
||||
if (Array.isArray(offsets) && offsets.length > 0) {
|
||||
return _tuningNameFromOffsets(offsets);
|
||||
}
|
||||
if (value && typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed === 'Unknown') return '';
|
||||
if (!_looksLikeRawTuningOffsets(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
// A raw offset string (now served by the API) — parse and name it so a
|
||||
// known tuning like "-1 -1 -1 -1 -1 -1" reads "Eb Standard" rather than
|
||||
// collapsing to "Custom Tuning".
|
||||
const parsed = (typeof parseRawTuningOffsets === 'function')
|
||||
? parseRawTuningOffsets(trimmed) : null;
|
||||
if (parsed && parsed.length) return _tuningNameFromOffsets(parsed);
|
||||
return 'Custom Tuning';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
window.displayTuningName = displayTuningName;
|
||||
window.feedBack = window.feedBack || {};
|
||||
window.slopsmith = window.feedBack;
|
||||
window.feedBack.displayTuningName = displayTuningName;
|
||||
|
||||
function isBassArrangement(context) {
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
if (typeof ctx.isBass === 'boolean') return ctx.isBass;
|
||||
const label = ((ctx.arrangement || '') + ' ' + (ctx.arrangement_smart_name || '')).toLowerCase();
|
||||
if (/\bbass\b/.test(label)) return true;
|
||||
if (/\b(lead|rhythm|combo|guitar)\b/.test(label)) return false;
|
||||
return false;
|
||||
}
|
||||
|
||||
function effectiveStringCount(offsets, context) {
|
||||
if (!Array.isArray(offsets) || !offsets.length) return 0;
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
const isBass = isBassArrangement(ctx);
|
||||
let sc = ctx.stringCount > 0 ? Number(ctx.stringCount) : 0;
|
||||
if (!isBass) {
|
||||
if (sc > 0 && sc <= 5 && offsets.length >= 6) sc = 6;
|
||||
if (!sc) sc = offsets.length >= 6 ? offsets.length : 6;
|
||||
} else if (!sc) {
|
||||
sc = offsets.length >= 5 ? offsets.length : 4;
|
||||
}
|
||||
return Math.min(sc, offsets.length);
|
||||
}
|
||||
|
||||
function songTuningContext(songInfo) {
|
||||
if (!songInfo || typeof songInfo !== 'object') return {};
|
||||
return {
|
||||
stringCount: songInfo.stringCount,
|
||||
arrangement: songInfo.arrangement,
|
||||
arrangement_smart_name: songInfo.arrangement_smart_name,
|
||||
};
|
||||
}
|
||||
|
||||
window.feedBack.isBassArrangement = isBassArrangement;
|
||||
window.feedBack.effectiveStringCount = effectiveStringCount;
|
||||
window.feedBack.songTuningContext = songTuningContext;
|
||||
|
||||
// Open-string target notes (display only) — mirrors plugins/tuner/utils/tuning-utils.js.
|
||||
const _TUNING_BASE_MIDI = {
|
||||
4: [28, 33, 38, 43],
|
||||
5: [23, 28, 33, 38, 43],
|
||||
6: [40, 45, 50, 55, 59, 64],
|
||||
7: [35, 40, 45, 50, 55, 59, 64],
|
||||
8: [30, 35, 40, 45, 50, 55, 59, 64],
|
||||
};
|
||||
const _TUNING_NOTE_SHARP = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
const _TUNING_NOTE_FLAT = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
|
||||
|
||||
function _tuningMidiToFreq(m) {
|
||||
return Math.pow(2, (m - 69) / 12) * 440;
|
||||
}
|
||||
|
||||
function _tuningOffsetsToFreqs(offsets, isBass) {
|
||||
const len = offsets.length;
|
||||
let base;
|
||||
if (len === 4 || len === 5) {
|
||||
base = isBass ? _TUNING_BASE_MIDI[len] : _TUNING_BASE_MIDI[6];
|
||||
} else {
|
||||
base = _TUNING_BASE_MIDI[len] || _TUNING_BASE_MIDI[6];
|
||||
}
|
||||
return offsets.map((offset, i) => {
|
||||
const root = i < base.length ? base[i] : base[base.length - 1];
|
||||
return _tuningMidiToFreq(root + offset);
|
||||
});
|
||||
}
|
||||
|
||||
function _noteNameFromFreq(freq, useFlats) {
|
||||
const midi = 69 + 12 * Math.log2(freq / 440);
|
||||
const rounded = Math.round(midi);
|
||||
const names = useFlats ? _TUNING_NOTE_FLAT : _TUNING_NOTE_SHARP;
|
||||
return names[((rounded % 12) + 12) % 12];
|
||||
}
|
||||
|
||||
function _octaveNoteFromFreq(freq, useFlats) {
|
||||
const midi = 69 + 12 * Math.log2(freq / 440);
|
||||
const rounded = Math.round(midi);
|
||||
const octave = Math.floor(rounded / 12) - 1;
|
||||
return _noteNameFromFreq(freq, useFlats) + octave;
|
||||
}
|
||||
|
||||
function _stringOrdinalLabel(n) {
|
||||
const v = n % 100;
|
||||
if (v >= 11 && v <= 13) return n + 'th';
|
||||
const suffix = { 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] || 'th';
|
||||
return n + suffix;
|
||||
}
|
||||
|
||||
function _tuningTargetFreqs(offsets, context) {
|
||||
if (!Array.isArray(offsets) || !offsets.length) return [];
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
const stringCount = effectiveStringCount(offsets, ctx);
|
||||
const trimmed = offsets.slice(0, stringCount);
|
||||
if (!trimmed.length) return [];
|
||||
const isBass = isBassArrangement(ctx);
|
||||
try {
|
||||
return _tuningOffsetsToFreqs(trimmed, isBass);
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Flat vs sharp spelling. A caller that knows the preference can pass
|
||||
// ctx.useFlats; otherwise we infer from a flat-keyed tuning name. The v3
|
||||
// card/HUD pass "Custom Tuning" (raw offsets carry no key), so those default
|
||||
// to sharps unless an explicit useFlats is supplied.
|
||||
function _resolveTargetUseFlats(ctx) {
|
||||
if (typeof ctx.useFlats === 'boolean') return ctx.useFlats;
|
||||
return typeof ctx.tuningName === 'string' && /\b[A-G]b\b/.test(ctx.tuningName);
|
||||
}
|
||||
|
||||
function displayTuningTargetDetails(offsets, context) {
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
const useFlats = _resolveTargetUseFlats(ctx);
|
||||
const freqs = _tuningTargetFreqs(offsets, ctx);
|
||||
return freqs.map((f, i) => {
|
||||
const stringNumber = freqs.length - i;
|
||||
const note = _noteNameFromFreq(f, useFlats);
|
||||
const octaveNote = _octaveNoteFromFreq(f, useFlats);
|
||||
return {
|
||||
stringNumber,
|
||||
note,
|
||||
octaveNote,
|
||||
title: _stringOrdinalLabel(stringNumber) + ' string: ' + octaveNote,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function displayTuningTargets(offsets, context) {
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
const useFlats = _resolveTargetUseFlats(ctx);
|
||||
const freqs = _tuningTargetFreqs(offsets, ctx);
|
||||
if (!freqs.length) return '';
|
||||
return freqs.map((f) => _noteNameFromFreq(f, useFlats)).join(' ');
|
||||
}
|
||||
|
||||
function parseRawTuningOffsets(value) {
|
||||
if (Array.isArray(value) && value.length) return value;
|
||||
if (!value || typeof value !== 'string') return null;
|
||||
const s = value.trim();
|
||||
if (/^-?\d+(?: -?\d+)+$/.test(s)) {
|
||||
return s.split(/\s+/).map((n) => Number(n));
|
||||
}
|
||||
if (/^-?\d+(?:,-?\d+)+$/.test(s)) {
|
||||
return s.split(',').map((n) => Number(n));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
window.displayTuningTargets = displayTuningTargets;
|
||||
window.displayTuningTargetDetails = displayTuningTargetDetails;
|
||||
@@ -3288,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) {
|
||||
@@ -3684,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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// Tuning display — naming, string counts, and target frequencies.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
|
||||
//
|
||||
// Turns raw per-string semitone offsets into things a human reads: a tuning NAME
|
||||
// ("Drop D", "Eb Standard", or a raw-offsets fallback), whether an arrangement is
|
||||
// bass, its effective string count, and the target FREQUENCIES + note names the
|
||||
// tuner checks against. Pure functions over a small MIDI/note-name table.
|
||||
//
|
||||
// The window / window.feedBack assignments for these stay in app.js — they are the
|
||||
// public contract (constitution II names window.feedBack), and app.js re-exposes
|
||||
// the imported bindings from exactly where it always did, so nothing about the
|
||||
// surface or its ordering changes.
|
||||
|
||||
// Display-only tuning label helpers — never mutate offsets or affect playback.
|
||||
function _looksLikeRawTuningOffsets(str) {
|
||||
if (!str || typeof str !== 'string') return false;
|
||||
const s = str.trim();
|
||||
if (!s) return false;
|
||||
if (/^-?\d+$/.test(s)) return true;
|
||||
if (/^-?\d+(?: -?\d+)+$/.test(s)) return true;
|
||||
if (/^-?\d+(?:,-?\d+)+$/.test(s)) return true;
|
||||
if (/^-?\d+(-?\d+){2,}$/.test(s)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function _tuningNameFromOffsets(offsets) {
|
||||
if (!offsets || !offsets.length) return '';
|
||||
const standard = {
|
||||
0: 'E Standard', '-1': 'Eb Standard', '-2': 'D Standard',
|
||||
'-3': 'C# Standard', '-4': 'C Standard', '-5': 'B Standard',
|
||||
'-6': 'Bb Standard', '-7': 'A Standard',
|
||||
1: 'F Standard', 2: 'F# Standard',
|
||||
};
|
||||
// Uniform offsets across 4 (bass) / 5 / 6 strings name the same Standard;
|
||||
// a 4-string bass [0,0,0,0] must read "E Standard", not "Custom Tuning".
|
||||
if (offsets.length >= 4 && offsets.every((o) => o === offsets[0])) {
|
||||
const name = standard[offsets[0]];
|
||||
if (name) return name;
|
||||
}
|
||||
if (offsets.length >= 4 && offsets[0] === offsets[1] - 2
|
||||
&& offsets.slice(1).every((o) => o === offsets[1])) {
|
||||
const noteNames = ['E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B', 'C', 'C#', 'D', 'Eb'];
|
||||
return 'Drop ' + noteNames[((offsets[0] % 12) + 12) % 12];
|
||||
}
|
||||
const named = {
|
||||
'-2,0,0,0,0,0': 'Drop D',
|
||||
'-4,-2,-2,-2,-2,-2': 'Drop C',
|
||||
'-2,-2,0,0,0,0': 'Double Drop D',
|
||||
'0,0,0,-1,0,0': 'Open G',
|
||||
'-2,-2,0,0,-2,-2': 'Open D',
|
||||
'-2,0,0,0,-2,0': 'DADGAD',
|
||||
'0,2,2,1,0,0': 'Open E',
|
||||
'-2,0,0,2,3,2': 'Open D (alt)',
|
||||
};
|
||||
if (offsets.length === 6) {
|
||||
const key = offsets.join(',');
|
||||
if (named[key]) return named[key];
|
||||
}
|
||||
return 'Custom Tuning';
|
||||
}
|
||||
|
||||
export function displayTuningName(value, offsets) {
|
||||
// Explicit offsets win — always name them.
|
||||
if (Array.isArray(offsets) && offsets.length > 0) {
|
||||
return _tuningNameFromOffsets(offsets);
|
||||
}
|
||||
if (value && typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed === 'Unknown') return '';
|
||||
if (!_looksLikeRawTuningOffsets(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
// A raw offset string (now served by the API) — parse and name it so a
|
||||
// known tuning like "-1 -1 -1 -1 -1 -1" reads "Eb Standard" rather than
|
||||
// collapsing to "Custom Tuning".
|
||||
const parsed = (typeof parseRawTuningOffsets === 'function')
|
||||
? parseRawTuningOffsets(trimmed) : null;
|
||||
if (parsed && parsed.length) return _tuningNameFromOffsets(parsed);
|
||||
return 'Custom Tuning';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function isBassArrangement(context) {
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
if (typeof ctx.isBass === 'boolean') return ctx.isBass;
|
||||
const label = ((ctx.arrangement || '') + ' ' + (ctx.arrangement_smart_name || '')).toLowerCase();
|
||||
if (/\bbass\b/.test(label)) return true;
|
||||
if (/\b(lead|rhythm|combo|guitar)\b/.test(label)) return false;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function effectiveStringCount(offsets, context) {
|
||||
if (!Array.isArray(offsets) || !offsets.length) return 0;
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
const isBass = isBassArrangement(ctx);
|
||||
let sc = ctx.stringCount > 0 ? Number(ctx.stringCount) : 0;
|
||||
if (!isBass) {
|
||||
if (sc > 0 && sc <= 5 && offsets.length >= 6) sc = 6;
|
||||
if (!sc) sc = offsets.length >= 6 ? offsets.length : 6;
|
||||
} else if (!sc) {
|
||||
sc = offsets.length >= 5 ? offsets.length : 4;
|
||||
}
|
||||
return Math.min(sc, offsets.length);
|
||||
}
|
||||
|
||||
export function songTuningContext(songInfo) {
|
||||
if (!songInfo || typeof songInfo !== 'object') return {};
|
||||
return {
|
||||
stringCount: songInfo.stringCount,
|
||||
arrangement: songInfo.arrangement,
|
||||
arrangement_smart_name: songInfo.arrangement_smart_name,
|
||||
};
|
||||
}
|
||||
|
||||
// Open-string target notes (display only) — mirrors plugins/tuner/utils/tuning-utils.js.
|
||||
const _TUNING_BASE_MIDI = {
|
||||
4: [28, 33, 38, 43],
|
||||
5: [23, 28, 33, 38, 43],
|
||||
6: [40, 45, 50, 55, 59, 64],
|
||||
7: [35, 40, 45, 50, 55, 59, 64],
|
||||
8: [30, 35, 40, 45, 50, 55, 59, 64],
|
||||
};
|
||||
|
||||
const _TUNING_NOTE_SHARP = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
|
||||
const _TUNING_NOTE_FLAT = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
|
||||
|
||||
function _tuningMidiToFreq(m) {
|
||||
return Math.pow(2, (m - 69) / 12) * 440;
|
||||
}
|
||||
|
||||
function _tuningOffsetsToFreqs(offsets, isBass) {
|
||||
const len = offsets.length;
|
||||
let base;
|
||||
if (len === 4 || len === 5) {
|
||||
base = isBass ? _TUNING_BASE_MIDI[len] : _TUNING_BASE_MIDI[6];
|
||||
} else {
|
||||
base = _TUNING_BASE_MIDI[len] || _TUNING_BASE_MIDI[6];
|
||||
}
|
||||
return offsets.map((offset, i) => {
|
||||
const root = i < base.length ? base[i] : base[base.length - 1];
|
||||
return _tuningMidiToFreq(root + offset);
|
||||
});
|
||||
}
|
||||
|
||||
function _noteNameFromFreq(freq, useFlats) {
|
||||
const midi = 69 + 12 * Math.log2(freq / 440);
|
||||
const rounded = Math.round(midi);
|
||||
const names = useFlats ? _TUNING_NOTE_FLAT : _TUNING_NOTE_SHARP;
|
||||
return names[((rounded % 12) + 12) % 12];
|
||||
}
|
||||
|
||||
function _octaveNoteFromFreq(freq, useFlats) {
|
||||
const midi = 69 + 12 * Math.log2(freq / 440);
|
||||
const rounded = Math.round(midi);
|
||||
const octave = Math.floor(rounded / 12) - 1;
|
||||
return _noteNameFromFreq(freq, useFlats) + octave;
|
||||
}
|
||||
|
||||
function _stringOrdinalLabel(n) {
|
||||
const v = n % 100;
|
||||
if (v >= 11 && v <= 13) return n + 'th';
|
||||
const suffix = { 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] || 'th';
|
||||
return n + suffix;
|
||||
}
|
||||
|
||||
function _tuningTargetFreqs(offsets, context) {
|
||||
if (!Array.isArray(offsets) || !offsets.length) return [];
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
const stringCount = effectiveStringCount(offsets, ctx);
|
||||
const trimmed = offsets.slice(0, stringCount);
|
||||
if (!trimmed.length) return [];
|
||||
const isBass = isBassArrangement(ctx);
|
||||
try {
|
||||
return _tuningOffsetsToFreqs(trimmed, isBass);
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Flat vs sharp spelling. A caller that knows the preference can pass
|
||||
// ctx.useFlats; otherwise we infer from a flat-keyed tuning name. The v3
|
||||
// card/HUD pass "Custom Tuning" (raw offsets carry no key), so those default
|
||||
// to sharps unless an explicit useFlats is supplied.
|
||||
function _resolveTargetUseFlats(ctx) {
|
||||
if (typeof ctx.useFlats === 'boolean') return ctx.useFlats;
|
||||
return typeof ctx.tuningName === 'string' && /\b[A-G]b\b/.test(ctx.tuningName);
|
||||
}
|
||||
|
||||
export function displayTuningTargetDetails(offsets, context) {
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
const useFlats = _resolveTargetUseFlats(ctx);
|
||||
const freqs = _tuningTargetFreqs(offsets, ctx);
|
||||
return freqs.map((f, i) => {
|
||||
const stringNumber = freqs.length - i;
|
||||
const note = _noteNameFromFreq(f, useFlats);
|
||||
const octaveNote = _octaveNoteFromFreq(f, useFlats);
|
||||
return {
|
||||
stringNumber,
|
||||
note,
|
||||
octaveNote,
|
||||
title: _stringOrdinalLabel(stringNumber) + ' string: ' + octaveNote,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function displayTuningTargets(offsets, context) {
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
const useFlats = _resolveTargetUseFlats(ctx);
|
||||
const freqs = _tuningTargetFreqs(offsets, ctx);
|
||||
if (!freqs.length) return '';
|
||||
return freqs.map((f) => _noteNameFromFreq(f, useFlats)).join(' ');
|
||||
}
|
||||
|
||||
export function parseRawTuningOffsets(value) {
|
||||
if (Array.isArray(value) && value.length) return value;
|
||||
if (!value || typeof value !== 'string') return null;
|
||||
const s = value.trim();
|
||||
if (/^-?\d+(?: -?\d+)+$/.test(s)) {
|
||||
return s.split(/\s+/).map((n) => Number(n));
|
||||
}
|
||||
if (/^-?\d+(?:,-?\d+)+$/.test(s)) {
|
||||
return s.split(',').map((n) => Number(n));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -7,20 +7,23 @@ const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The tuning-display helpers were carved out of app.js into their own module (R3a);
|
||||
// the autoplay-gate test below still reads app.js.
|
||||
const TUNING_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
|
||||
const TUNER_SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.js');
|
||||
const TUNING_UTILS_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'tuning-utils.js');
|
||||
const TUNER_UI_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
|
||||
|
||||
function loadTuningHelpers() {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const start = src.indexOf('function isBassArrangement(');
|
||||
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
|
||||
const end = src.indexOf(endMarker);
|
||||
if (start === -1 || end === -1) throw new Error('tuning helper block not found in app.js');
|
||||
const src = fs.readFileSync(TUNING_JS, 'utf8');
|
||||
// The module is nothing BUT the tuning helpers now, so there is no block to
|
||||
// slice out — take it whole. `export` is stripped so the vm sandbox can still
|
||||
// evaluate it as a plain script (the window.* contract lives in app.js).
|
||||
const body = src.replace(/^export /gm, '');
|
||||
const sandbox = { window: { feedBack: {} }, exports: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
src.slice(start, end + endMarker.length),
|
||||
body,
|
||||
sandbox
|
||||
);
|
||||
return sandbox.window.feedBack;
|
||||
|
||||
@@ -6,7 +6,8 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The tuning-display helpers were carved out of app.js into their own module (R3a).
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
|
||||
const HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
const V3_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The tuning-display helpers were carved out of app.js into their own module (R3a).
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
|
||||
const HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
const TUNER_UI_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
|
||||
const TUNER_SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.js');
|
||||
@@ -14,14 +15,14 @@ const V3_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
|
||||
function loadTuningHelpers() {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const start = src.indexOf('function isBassArrangement(');
|
||||
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
|
||||
const end = src.indexOf(endMarker);
|
||||
if (start === -1 || end === -1) throw new Error('tuning helper block not found in app.js');
|
||||
// The module is nothing BUT the tuning helpers now, so there is no block to
|
||||
// slice out — take it whole. `export` is stripped so the vm sandbox can still
|
||||
// evaluate it as a plain script (the window.* contract lives in app.js).
|
||||
const body = src.replace(/^export /gm, '');
|
||||
const sandbox = { window: { feedBack: {} }, exports: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
src.slice(start, end + endMarker.length) + '\n'
|
||||
body + '\n'
|
||||
+ 'exports.displayTuningTargets = displayTuningTargets;\n'
|
||||
+ 'exports.displayTuningTargetDetails = displayTuningTargetDetails;\n'
|
||||
+ 'exports.isBassArrangement = isBassArrangement;\n'
|
||||
|
||||
@@ -7,7 +7,8 @@ const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The tuning-display helpers were carved out of app.js into their own module (R3a).
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
|
||||
|
||||
function extractBlock(src, startMarker) {
|
||||
const start = src.indexOf(startMarker);
|
||||
@@ -27,14 +28,14 @@ function extractBlock(src, startMarker) {
|
||||
|
||||
function loadTuningHelpers() {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const start = src.indexOf('function _looksLikeRawTuningOffsets(');
|
||||
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
|
||||
const end = src.indexOf(endMarker);
|
||||
if (start === -1 || end === -1) throw new Error('tuning helpers not found');
|
||||
// The module is nothing BUT the tuning helpers now, so there is no block to
|
||||
// slice out — take it whole. `export` is stripped so the vm sandbox can still
|
||||
// evaluate it as a plain script (the window.* contract lives in app.js).
|
||||
const body = src.replace(/^export /gm, '');
|
||||
const sandbox = { window: { feedBack: {} }, exports: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
src.slice(start, end + endMarker.length) + '\n'
|
||||
body + '\n'
|
||||
+ 'exports.displayTuningName = displayTuningName;\n'
|
||||
+ 'exports.displayTuningTargets = displayTuningTargets;\n'
|
||||
+ 'exports.parseRawTuningOffsets = parseRawTuningOffsets;',
|
||||
|
||||
Reference in New Issue
Block a user