feat(folder_library): Folder Library core plugin (#610)

Adds the bundled Folder Library plugin (browse the DLC library by its on-disk
folder tree, in-app folder CRUD, drag-and-drop + dialog song moves, sort/filter,
live search), wired into the classic v2 toolbar and the v3 Songs page.

Includes the screen.js IIFE dedup (unified surface factory) and review fixes:
path-traversal guard on /song/move, folder-delete data-loss fix, plural
/api/plugins/<id> namespace, loose-folder song recognition, error-text escaping,
v3 setLibView null-guard, and tests.

Co-authored-by: Kyle <kyle.j.t@live.co.uk>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kyle
2026-06-27 16:03:54 +02:00
committed by GitHub
co-authored by Kyle Claude Opus 4.8
parent d1f7f12293
commit 3b2d83d406
13 changed files with 3030 additions and 7 deletions
+35 -2
View File
@@ -1082,7 +1082,7 @@ const _LIB_VIEW_KEY = 'feedBack.libView';
const _LIB_SORT_KEY = 'feedBack.libSort';
const _LIB_FORMAT_KEY = 'feedBack.libFormat';
const _LIB_PROVIDER_KEY = 'feedBack.libProvider';
const _LIB_VIEW_VALUES = new Set(['grid', 'tree']);
const _LIB_VIEW_VALUES = new Set(['grid', 'tree', 'folder']);
const _LIB_SORT_VALUES = new Set([
'artist', 'artist-desc', 'title', 'title-desc',
'recent', 'year-desc', 'year', 'tuning',
@@ -1760,8 +1760,20 @@ function setLibView(view) {
document.getElementById('lib-tree').classList.toggle('hidden', view !== 'tree');
document.querySelectorAll('.lib-grid-ctrl').forEach(el => el.classList.toggle('hidden', view !== 'grid'));
document.querySelectorAll('.lib-tree-ctrl').forEach(el => el.classList.toggle('hidden', view !== 'tree'));
document.querySelectorAll('.lib-nontree-ctrl').forEach(el => el.classList.toggle('hidden', view === 'tree'));
document.getElementById('view-grid-btn').className = `px-3 py-2.5 text-sm transition ${view === 'grid' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`;
document.getElementById('view-tree-btn').className = `px-3 py-2.5 text-sm transition ${view === 'tree' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`;
// Folder view
const folderTreeEl = document.getElementById('lib-folder-tree');
if (folderTreeEl) folderTreeEl.classList.toggle('hidden', view !== 'folder');
const folderCtrlEl = document.getElementById('lib-folder-controls');
if (folderCtrlEl) folderCtrlEl.classList.toggle('hidden', view !== 'folder');
// The folder-view toolbar button only exists in the classic (v2) markup;
// setLibView also runs at v3 startup where it's absent, so guard it (the
// grid/tree buttons above predate this and exist on both paths).
const folderBtnEl = document.getElementById('view-folder-btn');
if (folderBtnEl) folderBtnEl.className = `px-3 py-2.5 text-sm transition ${view === 'folder' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`;
if (libView === 'folder' && view !== 'folder') window.folderLibrary?.unload?.();
if (view !== 'grid') stopInfiniteScroll();
_libEpoch++;
// View toggle changes which container `_libNavItems` resolves
@@ -1774,11 +1786,32 @@ function setLibView(view) {
async function loadLibrary(page) {
if (libView === 'grid') {
await loadGridPage(page !== undefined ? page : currentPage);
} else {
} else if (libView === 'tree') {
await loadTreeView();
} else if (libView === 'folder') {
if (window.folderLibrary) await window.folderLibrary.load();
}
// v3 Songs page manages its own view state independently of libView — if
// lib-folder-tree is visible, the folder library must also react to filter changes.
if (libView !== 'folder' && window.folderLibrary) {
const treeEl = document.getElementById('lib-folder-tree');
if (treeEl && !treeEl.classList.contains('hidden')) {
await window.folderLibrary.load();
}
}
}
// ── Folder Library: filter bridge ─────────────────────────────────────────
// Serialises the active lib filter state as URL params so the plugin can pass
// them to /api/plugins/folder_library/tree — the same pattern grid and tree
// views use when sending filter params to their own backend endpoints.
window.feedBackLibFilterParams = function() {
var p = new URLSearchParams();
_applyLibFiltersToParams(p);
return p.toString();
};
async function _fetchJsonOrThrow(url) {
const resp = await fetch(url);
const raw = await resp.text();