Files
feedBack/static/capabilities/library-card-actions.js
T
af2949677a rename: slopsmith → feedBack, byron → got-feedBack (#537)
* Update GitHub repo references from feedback* to feedBack*

* rename: slopsmith -> feedBack, byron -> got-feedBack

Renames across the entire codebase:
- slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack
- byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack
- /home/byron/ -> /opt/got-feedBack/
- byron@ougsoft.com -> hi@got-feedBack.org
- github.com/byrongamatos/ -> github.com/got-feedback/
- com.byron. -> com.got-feedback.
- SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback
- Protocol/storage strings migrated with read-old/write-new pattern
- window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias

Refs: #rename-slopsmith

* rename: complete regen against current main + fix backward-compat alias

Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of
current main (3 commits had landed since the branch: #572/#554/#574),
resolving the four content conflicts in favour of main's newer content
(autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge).

Completion fixes on top of the mechanical rename:
- Re-apply rename to post-branch content the original rename never saw:
  window.slopsmith(.Tour) consumers in lessons.js / notifications.js /
  onboarding-tour.js, and the matching JS + python tests (autoplay_exit,
  progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env
  vars now match server.py (which reads FEEDBACK_SYNC_STARTUP /
  FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real
  path again.
- Restore the window.slopsmith backward-compat alias dropped during conflict
  resolution, and move the bus aliases to AFTER the _feedBackExisting merge
  block so they reference the fully-assembled object (also fixes the
  loop_api.test.js API-surface regex, which the original PR latently broke).
- Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR)
  and gitignore it.
- Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped
  source labels.

Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669
passed / 1683 collected with 0 import errors; zero residual slopsmith/byron
except the two intentional window.slopsmith aliases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* rename: implement advertised backward-compat + prune dead community plugins

Address gaps where PR #537's "Backward compatibility" section was advertised
but not implemented, and clean up the community plugin list.

Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured):
- New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py
  (_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and
  tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI /
  SLOPSMITH_PLUGINS_DIR / etc. deployments keep working.
- Fix the rename collapsing plugins/__init__.py and minigames/routes.py from
  `FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant
  `FEEDBACK_ or FEEDBACK_` (the fallback was silently lost).

Storage (app.js update-channel):
- Read feedBack-update-channel, fall back to legacy slopsmith-update-channel,
  and clear the legacy key on write — so a user's update-channel preference
  survives the rename instead of resetting to "stable".

Community plugin list (README): the rename rewrote third-party repo URLs we
don't own. Probed every one; their owners never renamed, so:
- Restore the 13 live community plugins to their real slopsmith-* names.
- Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex
  tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle).
- Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory).

Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors,
rename-sensitive + startup suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:01 +02:00

153 lines
7.4 KiB
JavaScript

/*
* ui.library-card-injection — core capability for plugin-contributed library
* card actions (fee[dB]ack v0.3.0).
*
* Replaces the legacy pattern where plugins (Sloppak Converter, Find More,
* editor) injected buttons by DOM-observing `.song-card`. Plugins now REGISTER
* card actions with placement, applicability, enabled state, and a run handler;
* the library renders + dispatches them and emits action-result events. The
* owner is registered in the capability runtime so the Capability Inspector and
* diagnostics can see it (design/05; docs/capability-roadmap.md domain #9).
*
* Public API: window.feedBack.libraryCardActions
* register(spec) -> unregister() spec: { id, pluginId, label, icon?,
* placement?('menu'|'inline'|'overlay'), order?, destructive?,
* applies?(song)->bool, enabled?(song)->bool, run(song, ctx) }
* list(song) -> [actionSummary] applicable actions, sorted
* run(id, song, ctx) -> Promise<{ ok, outcome, result?|error? }>
* snapshot() -> redaction-safe registry snapshot
*/
(function () {
'use strict';
const w = (typeof window !== 'undefined') ? window : {};
w.feedBack = w.feedBack || {};
if (w.feedBack.libraryCardActions && w.feedBack.libraryCardActions.version === 1) return;
const capabilities = w.feedBack.capabilities;
const DOMAIN = 'ui.library-card-injection';
const PLACEMENTS = ['menu', 'inline', 'overlay'];
const actions = new Map(); // id -> normalized spec
function _emit(event, payload) {
if (capabilities && typeof capabilities.emitEvent === 'function') {
try { capabilities.emitEvent(DOMAIN, event, payload); } catch (e) { /* */ }
}
}
function _normalize(spec) {
if (!spec || !spec.id || typeof spec.run !== 'function') return null;
return {
id: String(spec.id),
pluginId: String(spec.pluginId || 'unknown'),
label: String(spec.label || spec.id),
icon: spec.icon || '',
placement: PLACEMENTS.includes(spec.placement) ? spec.placement : 'menu',
order: Number.isFinite(spec.order) ? spec.order : 100,
destructive: !!spec.destructive,
applies: typeof spec.applies === 'function' ? spec.applies : () => true,
enabled: typeof spec.enabled === 'function' ? spec.enabled : () => true,
run: spec.run,
};
}
function register(spec) {
const a = _normalize(spec);
if (!a) { return () => {}; }
// Reject ID collisions: silently overwriting would make unregister()/
// run() nondeterministic once two providers share an action id.
if (actions.has(a.id)) {
if (typeof console !== 'undefined' && console.warn) {
console.warn('[library-card-actions] duplicate action id ignored:', a.id);
}
return () => {};
}
actions.set(a.id, a);
_emit('action-registered', { id: a.id, pluginId: a.pluginId, placement: a.placement });
return function unregister() {
if (actions.delete(a.id)) _emit('action-unregistered', { id: a.id, pluginId: a.pluginId });
};
}
function unregister(id) {
// Normalize the id once (so the lookup, delete, and event payload all
// agree for non-string ids) and capture the action before deleting, so
// the action-unregistered payload always carries a defined pluginId —
// matching the register()-returned unregister path.
const key = String(id);
const a = actions.get(key);
if (!actions.delete(key)) return;
_emit('action-unregistered', { id: key, pluginId: a ? a.pluginId : undefined });
}
function list(song) {
const out = [];
for (const a of actions.values()) {
let applicable = true;
try { applicable = a.applies(song) !== false; } catch (e) { applicable = false; }
if (!applicable) continue;
let enabled = true;
try { enabled = a.enabled(song) !== false; } catch (e) { enabled = true; }
out.push({ id: a.id, pluginId: a.pluginId, label: a.label, icon: a.icon, placement: a.placement, order: a.order, destructive: a.destructive, enabled });
}
out.sort((x, y) => (x.order - y.order) || x.label.localeCompare(y.label));
return out;
}
async function run(id, song, ctx) {
const a = actions.get(String(id));
if (!a) return { ok: false, outcome: 'no-action' };
// Mirror list()'s defensive semantics for the predicate checks: an
// applies() error means not-applicable, an enabled() error is non-fatal
// (treat as enabled). Only a.run() throwing is a 'failed' outcome.
let applicable = true;
try { applicable = a.applies(song) !== false; } catch (e) { applicable = false; }
if (!applicable) return { ok: false, outcome: 'not-applicable' };
let enabled = true;
try { enabled = a.enabled(song) !== false; } catch (e) { enabled = true; }
if (!enabled) return { ok: false, outcome: 'disabled' };
try {
const result = await a.run(song, ctx || {});
_emit('action-result', { id: a.id, pluginId: a.pluginId, outcome: 'handled' });
return { ok: true, outcome: 'handled', result };
} catch (e) {
_emit('action-result', { id: a.id, pluginId: a.pluginId, outcome: 'failed', reason: e && e.message ? e.message : String(e) });
return { ok: false, outcome: 'failed', error: e && e.message ? e.message : String(e) };
}
}
function snapshot() {
return {
schema: 'feedBack.library_card_actions.v1',
actions: Array.from(actions.values()).map((a) => ({ id: a.id, pluginId: a.pluginId, placement: a.placement, order: a.order, destructive: a.destructive })),
};
}
// Register the capability owner (Inspector/diagnostics visibility). The
// ergonomic register/list/run API above is what the library + plugins use;
// these commands mirror it for inspection.
if (capabilities && typeof capabilities.registerOwner === 'function') {
try {
capabilities.registerOwner(DOMAIN, {
pluginId: 'core.ui.library-card-injection',
kind: 'provider-coordinator',
commands: ['list', 'run', 'inspect'],
operations: ['action.run'],
events: ['action-registered', 'action-unregistered', 'action-result'],
ownership: 'multi-provider',
safety: 'safe',
version: 1,
description: 'Coordinates plugin-contributed library-card actions (placement, applicability, enabled state, action-result events).',
handlers: {
list: (ctx) => ({ outcome: 'handled', payload: list(ctx && ctx.payload && ctx.payload.song) }),
run: (ctx) => run(((ctx && ctx.payload) || {}).id, ((ctx && ctx.payload) || {}).song, ctx).then((r) => ({ outcome: r.ok ? 'handled' : (r.outcome === 'failed' ? 'failed' : 'denied'), payload: r })),
inspect: () => ({ outcome: 'handled', payload: snapshot() }),
},
});
} catch (e) { /* owner registration is best-effort */ }
}
const api = { version: 1, register, unregister, list, run, snapshot };
if (typeof module !== 'undefined' && module.exports) module.exports = api; // node tests
w.feedBack.libraryCardActions = api;
})();