From 3b2d83d406a82268a597767984bd5b12b421d5a8 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 27 Jun 2026 10:03:54 -0400 Subject: [PATCH] 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/ namespace, loose-folder song recognition, error-text escaping, v3 setLibView null-guard, and tests. Co-authored-by: Kyle Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + CHANGELOG.md | 1 + README.md | 1 - plugins/folder_library/CLAUDE.md | 345 ++++ plugins/folder_library/README.md | 100 ++ plugins/folder_library/plugin.json | 10 + plugins/folder_library/routes.py | 440 +++++ plugins/folder_library/screen.html | 159 ++ plugins/folder_library/screen.js | 1672 +++++++++++++++++++ static/app.js | 37 +- static/index.html | 8 +- static/v3/songs.js | 53 +- tests/plugins/folder_library/test_routes.py | 208 +++ 13 files changed, 3030 insertions(+), 7 deletions(-) create mode 100644 plugins/folder_library/CLAUDE.md create mode 100644 plugins/folder_library/README.md create mode 100644 plugins/folder_library/plugin.json create mode 100644 plugins/folder_library/routes.py create mode 100644 plugins/folder_library/screen.html create mode 100644 plugins/folder_library/screen.js create mode 100644 tests/plugins/folder_library/test_routes.py diff --git a/.gitignore b/.gitignore index c191124..2052cf8 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ plugins/achievements/__pycache__/ !plugins/highway_3d/ !plugins/highway_3d/** plugins/highway_3d/__pycache__/ +!plugins/folder_library/ +!plugins/folder_library/** +plugins/folder_library/__pycache__/ !plugins/app_tour_library/ !plugins/app_tour_library/** !plugins/app_tour_settings/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 94d2793..8140f6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Folder Library — a bundled core plugin (`plugins/folder_library/`) that browses the DLC library by its on-disk folder tree.** Surfaces top-level folders → subfolders → songs (root-level songs land in `(Unsorted)`), with in-app folder management (create / rename / delete nested folders), song moves via dialog or drag-and-drop, and sort/filter that mirrors the host library's filter state. Wired into both the classic (v2) library toolbar and the v3 Songs page as a third **Folders** view alongside grid/tree; the plugin's `screen.js` is loaded once by the host and reused (idempotent IIFEs). Supersedes the former standalone "Folder Organizer" community plugin (removed from the README list). Backend (`routes.py`) registers `/api/plugins/folder_library/{tree,folder/create,folder/rename,folder/delete,song/move}`; **all filesystem mutations are confined to `DLC_DIR` and validated against path traversal** (per-segment name validation plus a resolved-containment check on `song/move`), and folder deletion relocates every song — de-duplicating colliding names — so a name clash never destroys a song. A two-level cache keeps re-opening folders fast. Tests: `tests/plugins/folder_library/test_routes.py` (path-safety helpers + move-traversal and delete-no-data-loss end-to-end). - **Full-screen (immersive) plugin screens — opt-in via `"fullscreen": true` in `plugin.json`.** DAW-style plugin UIs (e.g. a practice studio) need the whole viewport, not a scrolling content page below the topbar — embedded in the v3 shell they get cut off at the bottom with dead space up top. A plugin can now declare a top-level `"fullscreen": true`; `plugins/__init__.py` surfaces it as the `fullscreen` boolean on `/api/plugins` (mirroring the `settings_category` plumbing). When such a plugin's screen is active, `static/v3/shell.js` toggles `html.fb-immersive` from `syncActive()` (so it tracks every navigation incl. deep-link), and `static/v3/v3.css` hides the topbar, collapses the sidebar to a functional **icon rail** (kept reachable — Escape is bound only on player/settings scopes, so a fully-hidden sidebar would trap the user), and lets the active plugin screen fill `#v3-main`. Mirrors the existing `ss-follower-pre` chrome-hide pattern. Additive + opt-in: plugins without the flag are unaffected. Tests: `tests/test_plugins.py::test_fullscreen_flag_parsed_from_manifest`. - **Achievements wall sync — background drain worker (epic PR3, client side).** The bundled `achievements` plugin gains a dead-letter sync worker that POSTs queued Feat unlocks (and removals) to the hosted **feedback-achievements** wall service (separate repo). Idle unless a wall URL is configured (`FEEDBACK_ACHIEVEMENTS_WALL_URL`); uses `requests` with the baked-in client-token header, mirroring `lib/lyrics_transcribe`'s outbound pattern (explicit timeout, no raise on non-2xx). **Dead-letter, never drop** (pure `engine.drain_decision`): network error / `429` / `5xx` → keep `pending` (retry); other `4xx` → `dead_letter` (diagnosable, replayable); `2xx` → delete on server ack. A row leaves the queue only on ack or a user opt-out. `remove-me` now enqueues a wall removal keyed by the reused `player_hash`. Verified by an end-to-end staging round-trip (earn a Feat → drains onto the wall with name + short hash → `remove-me` → wall empties) with **no IP** in tables or access logs. Tests: `tests/plugins/achievements/test_sync.py` (decision table + ack/retry/dead-letter retention + four-field payload on the wire). The hosted service itself (FastAPI + SQLite-on-disk, Feats-only, hidden-until-first-global-unlock, profanity filter, in-memory rate limit, Render blueprint, migration tool) lives in the new `feedback-achievements` repo. - **Achievements wall — opt-in, privacy controls & data-minimization gate (epic PR2).** Sharing earned **Feats** on the (forthcoming) public wall is strictly opt-in. A new **onboarding step** (`static/v3/profile.js`, inserted after song-directory / before instrument paths — the wizard is now five steps) presents a plain-language card: it publishes only your display name and the Feats you earn, never songs/skills/scores, and is **off by default**. The bundled plugin's Settings panel (`plugins/achievements/settings.html`, mounted under the **System** tab via `settings.category`) carries the same toggle plus a **"Remove me from the wall"** button (`POST /api/plugins/achievements/remove-me` — wipes local synced state offline + enqueues a wall removal). Core adds `achievements_enabled` (bool, default `false`) to `_default_settings()` + the `/api/settings` validation block + `_RESETTABLE_SETTINGS_KEYS` in `server.py`, mirrored to `localStorage` in `app.js loadSettings()`. **Data-minimization contract (binding, code-enforced):** every outbound payload is built by a single explicit-dict serializer (`engine.build_wall_payload`, never `dict(row)`/`**model`) whose key-set is **exactly** `{display_name, player_hash, achievement_id, unlocked_at}` with `achievement_id` always a **Feat** id — a unit test asserts the four-field set and goes red on a fifth. Enqueue is doubly gated: it happens only when opted-in **and** a profile identity (name + the reused `player_hash`) exists; **competency unlocks never enqueue** (integration law). Tests: `tests/plugins/achievements/test_datamin.py` (key-set, opt-out/identity/competency gating) + `tests/test_settings_api.py` (flag persists/validates/resettable). diff --git a/README.md b/README.md index 9ec3929..f3e7b40 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,6 @@ | [Update Manager](https://github.com/masc0t/slopsmith-update-manager) | Installs, updates, and uninstalls other plugins and the feedBack core itself | `git clone ...slopsmith-update-manager.git update_manager` | | [Simplify Chords](https://github.com/bkranendonk/slopsmith-plugin-simplify-chords) | Changes complex chords on the note highway to simpler ones. Inspired by Ultimate Guitar's Simplify button. | `git clone ...slopsmith-plugin-simplify-chords.git simplify-chords` | | [Key Bindings](https://github.com/jackipicco/slopsmith-plugin-key-bindings) | Highway key bindings for keyboard and TV remote | `git clone ...slopsmith-plugin-key-bindings.git key_bindings` | -| [Folder Organizer](https://github.com/Elit3d/slopsmith-plugin-folder-organizer) | Organize your sloppak DLC songs into a folder tree view, grouped by subfolder name | `git clone ...slopsmith-plugin-folder-organizer.git folder-organizer` | | [Virtuoso](https://github.com/got-feedback/feedBack-plugin-virtuoso) | Practice studio for guitar & bass — scale, technique, and rhythm drills, timed workouts, and jam backing that teach skills you take off the screen. | `git clone ...feedBack-plugin-virtuoso.git virtuoso` | | [Audio Preview](https://github.com/saleemk/slopsmith-plugin-audio-preview) | Quick audio previews from library cards with configurable start time, volume, and duration | `git clone ...slopsmith-plugin-audio-preview.git audio_preview` | | [Song Mastery](https://github.com/jamesgaiser/slopsmith-plugin-song-mastery) | Auto-adjusts difficulty based on your rolling note accuracy and saves the slider position per song | `git clone ...slopsmith-plugin-song-mastery.git song_mastery` | diff --git a/plugins/folder_library/CLAUDE.md b/plugins/folder_library/CLAUDE.md new file mode 100644 index 0000000..a44ea02 --- /dev/null +++ b/plugins/folder_library/CLAUDE.md @@ -0,0 +1,345 @@ +# Folder Library — AI Agent Guide + +A FeedBack (fee[dB]ack) plugin that adds a **Folders** nav screen showing your `.sloppak` / `.feedpak` DLC songs grouped by the folder tree on disk. Create, rename, and delete folders (including **nested subfolders**) directly in the UI, move songs by drag-and-drop, and browse with sort and metadata filters. + +> The host app is **FeedBack** (formerly "Slopsmith"). The frontend talks to the host through `window.feedBack`; `window.slopsmith` is a back-compat alias the host still exposes (`window.slopsmith = window.feedBack` in `static/app.js`). New code should prefer `window.feedBack`. + +> ⚠️ **Status — bundled core plugin.** This plugin began as a standalone plugin and is now a bundled core plugin. `screen.js` has been unified into a **single surface factory** driving two entry points: the v3 library Folder view (host chrome — host search `#v3-search`/`#lib-filter`, host filter params, renders into `#lib-folder-tree`) and the classic v2 standalone Folders nav-tab (its own `#fb-search` + toolbar, renders into `#fb-tree`). **Folder search works on both surfaces** — typing in the relevant search box re-renders the tree. **Loose-folder songs** (directories with audio + an arrangement XML) are recognised as songs via the host `loosefolder.is_loose_song` predicate, so they appear in the tree alongside `.sloppak`/`.feedpak` bundles. Folder management, nested subfolders, collapsible folders + expand/collapse-all, drag-and-drop, move-song, sort, filters, and the hover metadata badges are wired on both surfaces; verify against a running build before relying on any of it. + +## File Structure + +``` +plugin.json Plugin manifest — id, name, nav entry, file declarations ("bundled": true core plugin) +routes.py FastAPI backend — recursive DLC scan, folder tree + filters, folder/song mutations, two-level cache +screen.html Plugin screen content — injected by the host into the plugin div automatically +screen.js Frontend logic — recursive folder tree, search, sort, filters, drag-and-drop, modals +README.md User-facing docs +``` + +## Architecture + +This plugin follows the standard FeedBack plugin pattern (see the repo-root `CLAUDE.md` for the full plugin system reference). + +- **Backend** (`routes.py`) — registers routes under `GET/POST /api/plugins/folder_library/`. Uses `context["get_dlc_dir"]()`, `context["extract_meta"]()`, and `context["log"]`. Scans `/sloppak/` if it exists, otherwise `/`. Recursively walks the tree and handles create/rename/delete folder and move-song operations on slash-separated folder paths. +- **Frontend** (`screen.js`) — plain vanilla JS in an IIFE. Fetches the tree from the backend on screen load, recursively renders collapsible folder sections (any depth) and song rows or cards (grid view). Uses `window.feedBack.on('screen:changed', ...)` (via the `window.slopsmith` alias) to trigger load when the user navigates here. Calls `window.playSong(filename)` on song click with the full relative path from the DLC root. +- **No dependencies** — no npm, no build step. Tailwind utility classes available globally from the host; the plugin uses only core-guaranteed utilities and inline styles, so it ships **no** `styles` manifest key. + +## Critical Layout Lessons (Hard-Won) + +These are non-obvious behaviours of the FeedBack desktop app (Electron) that took significant debugging to discover. They still apply unchanged. + +### 1. Do NOT put an outer wrapper div in screen.html +The host automatically creates `
` and injects `screen.html` content inside it. If you add your own outer div with `class="screen"`, you get a nested screen element which gets `display:none` applied, hiding all content. + +**Wrong:** +```html +
+
toolbar
+
content
+
+``` + +**Correct:** +```html + +
toolbar
+
content
+``` + +### 2. The .screen CSS class sets display:none by default +`.screen { display: none }` and `.screen.active { display: block }`. There is no height set. The screen div gets its height purely from its content. Do not try to set height via CSS classes — use inline styles or JS if needed. + +### 3. The host navbar is position:fixed with z-index:50 +The navbar sits at `top:0, z-index:50`. Plugin toolbars must use `position:fixed; top:64px; z-index:40` to sit below the navbar. Use a solid `background-color` (not Tailwind bg classes — those may not apply correctly) to prevent content showing through. + +### 4. Content must have padding-top to clear the fixed toolbar +Since the toolbar is `position:fixed`, it floats above the content. The content container needs enough `padding-top` (~120px) to ensure the first item isn't hidden behind the toolbar — the host navbar (64px) plus the plugin toolbar height (~56px). Adding more toolbar buttons increases this height, so if content is clipped, increase the padding further. + +### 5. Electron blocks window.prompt() and window.confirm() +The desktop app is built on Electron, which throws `Error: prompt() is not supported`. Use a custom inline modal instead. See `_showModal()` in `screen.js` — it returns a Promise and supports both text input and confirm modes. + +### 6. The nav plugin dropdown has z-index:50 and blocks clicks +When navigating to a plugin screen via the Plugins dropdown, the dropdown stays open and sits on top of the screen. Call `_closeDropdown()` on screen load to dismiss it. The dropdown element id is `plugin-dropdown`. + +### 7. playSong() expects a relative path from the DLC root +`window.playSong()` expects the path relative to the DLC root with forward slashes, e.g. `sloppak/CH/Artist - Title.sloppak`. Not just the filename. The backend builds this in `_meta()` via `"/".join(p.relative_to(dlc).parts)` and returns it as each song's `filename`. + +### 8. FastAPI POST routes need `from fastapi import Request` +Routes that receive a JSON body must import `Request` from fastapi explicitly and use `async def route(request: Request)` with `body = await request.json()`. Missing this import crashes the server on plugin load. + +### 9. Plugin id must be consistent everywhere +The plugin id (`folder_library`) must match in: +- `plugin.json` → `"id"` and `"nav.screen"` +- `screen.js` → `PLUGIN_ID` constant and `API` constant (`/api/plugins/folder_library`) +- `routes.py` → `APIRouter(prefix="/api/plugins/folder_library")` + +A mismatch in any of these causes silent failures (blank screen, 404 API calls). + +### 10. Use inline styles for grid layout, not Tailwind +Tailwind's `grid` and `grid-cols-*` classes may not apply reliably inside the plugin div. Use `element.style.cssText` with explicit `display:grid; grid-template-columns:...` for the grid container. + +## Key Conventions + +- **IIFE + `'use strict'`** — all frontend code wrapped in `(function(){ 'use strict'; ... })();` +- **localStorage prefixes** — plugin keys are prefixed `fo:` (e.g. `fo:view`, `fo:sort`, `fo:filters`); host-library-synced filter state uses `fo:lib:`. Open-folder state is tracked by **folder path** (so nested folders each remember their own state). +- **Safe storage access** — all `localStorage` reads/writes wrapped in try/catch +- **Logging** — backend uses `context["log"]`, never `print()` +- **Sibling imports** — use `context["load_sibling"]("name")` not bare `import name` (none needed today; keep this in mind if you add helper modules) + +## Song Formats + +The plugin treats both `.sloppak` and `.feedpak` as songs (`_is_song()` in `routes.py`). `feedpak` is the published name for the same on-disk format the codebase still calls `sloppak` internally — see the repo-root `CLAUDE.md`. Both file form (`.sloppak`/`.feedpak` zip) and directory form (`*.sloppak/` folder) are recognized. + +## Backend Routes + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/plugins/folder_library/tree` | Returns the folder tree. Accepts optional filter query params (below) applied server-side. | +| POST | `/api/plugins/folder_library/folder/create` | Body: `{name, parent?}` — creates a subfolder; `parent` (slash path) nests it inside an existing folder, omit/empty for top level | +| POST | `/api/plugins/folder_library/folder/rename` | Body: `{old, new}` — `old` is a slash path, `new` is a bare name; renames within the same parent | +| POST | `/api/plugins/folder_library/folder/delete` | Body: `{name}` (slash path) — moves all songs at any depth to the scan root, then removes the folder | +| POST | `/api/plugins/folder_library/song/move` | Body: `{filename, folder}` — moves a song to `folder` (slash path; empty = scan root / "Unsorted") | + +### `/tree` filter query params + +All optional, applied server-side over the cached full tree by `_apply_tree_filters()`. Comma-separated, case-insensitive: + +- `arrangements_has`, `arrangements_lacks` — include/exclude by arrangement name +- `stems_has`, `stems_lacks` — include/exclude by stem name +- `has_lyrics` — `""` (any), `"1"`, or `"0"` +- `tunings` — comma-separated tuning names to include + +The frontend forwards the host library's active filter params here (via `window.feedBackLibFilterParams()` when present, with `window.slopsmithLibFilterParams()` as a legacy fallback) so the Folders view can stay in sync with the main library filters, falling back to its own filter panel state otherwise. + +### Path safety + +`_safe_name()` rejects empty names, leading/trailing whitespace, the characters `\ / : * ? " < > |`, and `.`/`..`. `_safe_path()` applies `_safe_name()` to every slash-separated segment, so traversal (`..`) and absolute paths are rejected before any filesystem op. Always validate user-supplied folder paths through these before touching disk. + +## Tree Shape + +`/tree` returns: + +```json +{ + "folders": [ + { + "name": "CH", + "path": "CH", + "songs": [ /* song objects */ ], + "children": [ + { "name": "Live", "path": "CH/Live", "songs": [], "children": [] } + ] + } + ], + "root_songs": [ /* songs sitting directly in the scan root — shown as "Unsorted" */ ] +} +``` + +Folder nodes are **recursive**: each has `name`, `path` (slash-separated, relative to the scan root), `songs`, and `children`. The frontend renders any depth — `_findFolderByPath()`, `_countDeep()`, and `_countFoldersDeep()` walk the `children` arrays. + +## Song Metadata Format + +Each song object (built by `_meta()`): + +```json +{ + "filename": "sloppak/CH/Artist - Title.sloppak", + "title": "Title", + "artist": "Artist", + "album": "Album Name", + "duration": 213.5, + "year": 1993, + "tuning": "E Standard", + "added": 1748132400.0, + "arrangements": ["Lead", "Rhythm", "Bass"], + "stems": ["Drums", "Bass", "Vocals"], + "lyrics": true +} +``` + +- `filename` is the full relative path from the DLC root — pass it directly to `window.playSong()`. +- `added` is a Unix timestamp (float, seconds) from `stat().st_mtime` — convert with `new Date(added * 1000)`. Always recomputed fresh (it changes when a file moves), even on a metadata-cache hit. +- `arrangements` / `stems` are flat lists of **strings**, even though `extract_meta()` returns them as objects. + +### extract_meta returns arrangements/stems as objects, not strings + +`context["extract_meta"]()` returns arrangements as a list of objects `{index, name, notes}`, not plain strings; stems similarly. `_meta()` normalizes to `.name`: + +```python +raw_arr = raw.get("arrangements") or [] +m["arrangements"] = [ + a["name"] if isinstance(a, dict) else str(a) + for a in raw_arr + if (isinstance(a, dict) and "name" in a) or isinstance(a, str) +] +``` + +`lyrics` is coerced to a bool from several possible keys (`lyrics`, `hasLyrics`, `has_lyrics`, …). If you add new metadata fields from `extract_meta`, check the raw shape before assuming it's a plain value. + +## Two-Level Cache + +`routes.py` keeps two caches inside `setup()`: + +- **`_meta_cache`** — expensive `extract_meta()` results keyed by absolute POSIX path. **Never cleared.** When files move (rename/delete/move), the keys are rewritten in-place so the warm data survives the operation. +- **`_cache`** — the assembled tree structure (`folders` / `root_songs`). Cleared by `_invalidate()` on **every** mutation so the next `/tree` rebuilds it — but the rebuild is fast because `_meta_cache` is still warm. + +`filename` and `added` are deliberately **not** stored in `_meta_cache` (they depend on the file's current location) — they're recomputed on every `_meta()` call and merged onto the cached copy. When you add a mutation route, mirror the existing key-rewrite logic (see `rename_folder`, `delete_folder`, `move_song`) so the metadata cache stays valid. + +## Folder Scan Logic + +`routes.py` scans recursively starting at `/sloppak/` (or `/` if no `sloppak` subdir exists): +- Files/dirs matching `.sloppak` or `.feedpak` → song entries (root-level ones go to `root_songs`, shown as "Unsorted") +- Subdirectories → recursive folder nodes with their own `songs` + `children` +- Dot-prefixed entries are skipped; empty folders are still included (shown with a 0 count) + +To add more grouping options (by artist, album, etc.), build an alternative projection over the scanned songs rather than the on-disk tree. + +## Library provider (future, not implemented) + +This plugin surfaces folders as a dedicated **view** over the existing library; +it does not (yet) register itself as a selectable library **source/provider**. +If you want a "Folders" entry to appear in the host's main library-source +picker (mapping top-level folder → "artist", subfolder → "album"), implement a +provider exposing the source-aware contract (`query_page`, `query_artists`, +`query_stats`, `tuning_names`) and register it in `setup()` via +`context["register_library_provider"](...)`, unregistering on teardown. (An +earlier inert `FolderLibraryProvider` scaffold was removed — it was never wired +and only duplicated the scan logic; re-add it only alongside real registration +and tests.) + +## View Modes (List / Grid) + +The toolbar has a list/grid toggle. Current view is stored in `localStorage` under `fo:view` (`'list'` or `'grid'`). + +- **List view** — `_songRow()`, rendered inside a `ml-5 space-y-0` div +- **Grid view** — `_songCard()`, rendered inside a CSS grid div (`auto-fill, minmax(150px,1fr)`) +- Both the folder and unsorted section renderers branch on `_view` to pick the right renderer and container +- Album art is fetched via `/api/song//art` where each path segment is individually `encodeURIComponent`-encoded. On error the `` is hidden and a placeholder SVG is shown +- The collapse/expand toggle restores `display:grid` (not just `display:''`) when reopening a folder in grid mode — always check this when changing toggle logic + +### Lazy folder rendering + +Folders do **not** render their song list on initial load. The folder renderer sets a `_listPopulated` flag and only populates the list the first time a folder is opened, keeping the initial render fast with large libraries. When search is active all folders are forced open and populated immediately (search overrides lazy loading). + +## Sort System + +The toolbar has a sort select (`#fb-sort`) and a direction toggle (`#fb-sort-dir`). State is stored under `fo:sort` and `fo:sortDir`. + +- `_sort` — `'default' | 'title' | 'artist' | 'duration' | 'year' | 'tuning' | 'added'` +- `_sortDir` — `'asc' | 'desc'` +- `_sortSongs(songs)` returns a sorted copy; direction is applied by reversing after sort. Returns the array unchanged when `_sort === 'default'`. +- The sort direction button is dimmed (`opacity: 0.35`) and non-interactive when sort is `'default'`. + +## Filter System + +Client-side filters are stored under `fo:filters` as a JSON object. (The server `/tree` endpoint can also filter — see Backend Routes — used to sync with the host library.) + +### Filter state shape + +```js +_filters = { + arrangements: { Lead: 'on', Bass: 'exclude', Rhythm: 'off' }, + stems: { Drums: 'off' }, + lyrics: 'off', // 'off' | 'on' | 'exclude' + tunings: ['E Standard', 'Eb Standard'], +} +``` + +Each arrangement/stem value is `'off' | 'on' | 'exclude'`. + +### Include vs exclude logic + +`_matchFilters(song)` uses **OR logic for includes, AND logic for excludes**: + +- **Include (`'on'`)** — song passes if it has *at least one* selected arrangement/stem. More includes widens the result set. +- **Exclude (`'exclude'`)** — each excluded tag independently removes songs that have it. More excludes narrows the result set. + +This matches standard multi-select filter UX (Spotify/library style). + +### Data-driven filter panel + +All filter sections are built from the actual library data — nothing is hardcoded: + +- `_getArrangements()` — unique arrangement names sorted by frequency (most common first), then alphabetically +- `_getStems()` — same pattern for stem names +- `_getAvailableFilters()` — returns `{ arrangements, stems, lyrics, tuning }` booleans gating the lyrics/tuning sections + +Non-standard arrangement names (e.g. `"Bonus"`) appear as pills automatically — no constants to update. The stems section only appears if at least one song has stems data. + +### Split pill UI + +`_makeSplitPill(label, state, onChange)` renders a two-zone pill: +- Left zone (label) — toggles `'off' ↔ 'on'` (include, blue) +- Right zone (`✕`) — toggles `'off' ↔ 'exclude'` (exclude, red) + +The filter badge (`#fb-filter-badge`) shows the active filter count via `_activeFilterCount()`. + +## Hover Badges + +Each song row/card has two hidden hover-reveal layers, built once and toggled via CSS `max-height` + `opacity` transitions. + +### `_badge(text, active, type)` + +Renders a single metadata badge. Type controls the inactive colour: + +| type | inactive border | inactive text | +|---|---|---| +| `'arrangement'` | amber `#92400e` | amber `#fcd34d` | +| `'stem'` | violet `#5b21b6` | violet `#c4b5fd` | +| `'lyrics'` | rose `#9f1239` | rose `#fda4af` | +| `'tuning'` | teal `#0f766e` | teal `#5eead4` | + +Active state is always blue (`#1d4ed8` fill, `#3b82f6` border, white text) regardless of type. + +### `_buildSongBadges(song)` + +Builds the badge row (arrangements, stems, lyrics, tuning), deduplicating within each category. Clicking a badge toggles that filter on/off and re-renders. Returns `null` if the song has no filterable metadata. + +### `_buildSongDateInfo(song)` + +Builds a separate plain-text hover line showing `year · date added` (e.g. `1993 · 24 May 2026`), `#cbd5e1` text. Always shown on hover regardless of filter state. + +### Reveal / hide + +```js +_revealBadges(el) // max-height:120px, opacity:1, margin-top:4px +_hideBadges(el) // max-height:0, opacity:0, margin-top:0 +``` + +Both badge layers (badges + date-info) are wired to the same `mouseenter`/`mouseleave` events on the row or card element. + +## Drag-and-Drop + +Drag-and-drop uses **pointer events** (mousedown/mousemove/mouseup), not the HTML5 DnD API. HTML5 DnD blocks wheel events and gives unreliable edge positions inside Electron — pointer events give full control. + +- `_makeDraggable(el, song, folderName)` — attaches a `mousedown` listener. A drag goes "live" only after the pointer moves more than `_DRAG_THRESH` (5 px), preventing accidental drags on clicks. +- Once live, a ghost `div` follows the cursor. Auto-scroll activates when the pointer is within `_DRAG_ZONE` (150 px) of the viewport top/bottom. +- `_makeDropTarget(el, targetFolder)` — sets `data-dropFolder` so an element can receive drops. Both folder headers and song-list containers are drop targets — including **nested** folders (drop onto a subfolder header moves the song there). +- `_dragFindTarget(x, y)` — uses `document.elementsFromPoint` to find the topmost element with `data-dropFolder` under the cursor. +- **Esc to cancel** — `_onDragKeyDown` calls `_endPointerDrag()` on `Escape`, removing the ghost and clearing state without dropping. +- On a successful drop, `_executeDrop()` does an **optimistic UI update** (moves the song in the in-memory tree and re-renders) then calls `/song/move`. On API failure it reloads the full tree. +- A one-time `click` capture listener after mouseup suppresses the post-drag click so it doesn't trigger playback. + +## Modal Behaviour + +`_showModal(msg, withInput, defaultVal)` is the custom modal used for all prompts and confirms (Electron blocks `window.prompt()` / `window.confirm()`). It returns a Promise. + +- `_confirm(msg)` — resolves `true` on OK, `null` on cancel +- `_prompt(msg, default)` — resolves the trimmed input string on OK, `null` on cancel +- **Esc cancels** — resolves with `null`, same as Cancel (applies to rename, delete, create folder/subfolder, move song) +- **Enter confirms** — submits, equivalent to OK + +## Roadmap + +Implemented since the original release: **nested subfolders** (recursive tree + create-inside-folder), drag-and-drop, sort, advanced filtering, server-side tree filtering synced to the host library, and the warm metadata cache. + +Not yet implemented, in rough priority order: + +- **Auto-play on hover** — with an on/off toggle saved to localStorage. +- **Bulk move** — multi-select songs and move them all at once. +- **Thumbnail performance** — faster loading and smoother scrolling with large libraries. +- **Adjustable thumbnail/row sizes** — user-resizable song cards and list rows. +- **Custom themes** — switchable colour schemes. +- **Favoriting songs** — likely a new backend route plus a `fo:favorites` localStorage key. +- **Editing song metadata** — edit title, artist, album etc. in-plugin; needs new backend write routes. +- **Folders as a library source** — register a library provider so a "Folders" entry appears in the host's main library-source picker (see "Library provider (future)" above). diff --git a/plugins/folder_library/README.md b/plugins/folder_library/README.md new file mode 100644 index 0000000..2d102f8 --- /dev/null +++ b/plugins/folder_library/README.md @@ -0,0 +1,100 @@ +# Folder Library — FeedBack Plugin + +![Core plugin](https://img.shields.io/badge/fee%5BdB%5Dack-core%20plugin-blue) +![Platform](https://img.shields.io/badge/platform-fee%5BdB%5Dack-darkblue) + +A FeedBack (fee[dB]ack) plugin that organizes your `.sloppak` / `.feedpak` DLC songs into a folder tree, grouped by the folders on disk. Browse your whole library visually with album art, nest folders as deep as you like, switch between list and grid layouts, and manage folders without ever leaving the app. + +--- + +## Screenshots + +![Grid view](assets/grid-view.webp) +*Grid view — album art cards with title and artist* + +![Grid search](assets/grid-search.png) +*Live search filters instantly across all folders* + +![List view](assets/list-view.png) +*List view — compact rows with album art thumbnails and duration* + +![New folder](assets/new-folder.png) +*Create and manage folders directly in the UI* + +--- + +> **Status — migrating to core.** Folder Library is being reworked from a standalone plugin into a bundled core plugin, and several previously-shipped features are not currently wired up in core (see the Roadmap). The list below reflects what works today; if something here is wrong, it's because this rework is still in progress. + +## Features + +- **List & Grid views** — toggle between a compact list with thumbnails or a full album art card grid +- **Album art** — pulls art automatically for every song in both views +- **One-click playback** — click any song to start playing immediately +- **Sort options** — sort songs by title, artist, duration, year, tuning, or recently added with an asc/desc toggle +- **Advanced filters** — filter by arrangements, stems, lyrics, and tuning with include and exclude support +- **Folder management** — create, rename, and delete folders without leaving the plugin +- **Nested subfolders** — organize as deep as you want; create a subfolder inside any folder, expand/collapse a whole branch in one click +- **Collapsible folders** — expand/collapse individual folders, plus Expand All / Collapse All +- **Move songs** — reassign any song to a different folder on the fly; press `Esc` to cancel +- **Drag-and-drop** — drag songs between folders (including into nested folders) with smooth auto-scroll; press `Esc` to cancel +- **Fast with big libraries** — folder song lists render lazily and metadata is cached so reopening folders is instant + +--- + +## Installation + +Folder Library ships bundled with FeedBack as a core plugin (`"bundled": true`), so there's nothing to install — the **Folders** screen appears in the navbar under **Plugins** automatically. + +--- + +## Usage + +| Action | How | +|--------|-----| +| Switch to grid view | Click the grid icon in the toolbar | +| Switch to list view | Click the list icon in the toolbar | +| Play a song | Click any song row or card | +| Sort songs | Use the sort dropdown in the toolbar | +| Toggle sort direction | Click the arrow button next to the sort dropdown | +| Open filters | Click the filter icon in the toolbar | +| Filter by arrangement/stem | Open filters → click a pill to include; click `✕` to exclude | +| Clear all filters | Open filters → click "Clear all" | +| Create a folder | Click the folder+ icon in the toolbar | +| Create a subfolder | Hover a folder header → click the new-subfolder icon | +| Rename a folder | Hover the folder header → click the pencil icon | +| Delete a folder | Hover the folder header → click the trash icon (songs move up to Unsorted) | +| Move a song | Hover the song row → click the folder icon | +| Drag a song to a folder | Click and hold a song → drag to a folder header or body (nested folders work too) | +| Cancel a drag | Press `Esc` while holding a song | +| Cancel a move dialog | Press `Esc` in the move prompt | +| Expand / collapse a folder | Click the folder header | +| Expand / collapse all subfolders | Use the expand/collapse-children buttons on a folder with subfolders | + +--- + +## Changelog + +Folder Library started life as a standalone plugin with its own version line, but it's now a **bundled core plugin** that ships with FeedBack. Its changes are tracked alongside the app in the repo-root [CHANGELOG.md](../../CHANGELOG.md), and it versions with the app rather than on its own. The **Features** section above reflects what's in the current build. + +--- + +## Roadmap + +- [ ] Auto play song on hover (with an on/off toggle) +- [ ] Bulk move — select multiple songs and move them at once +- [ ] Thumbnail performance — faster loading and smoother scrolling with large song libraries +- [ ] Adjustable thumbnail and row sizes — resize song cards and list rows to suit your preference +- [ ] Custom themes — switch between colour schemes to match your style +- [ ] Favoriting songs +- [ ] Editing song metadata + +--- + +## Contributing + +Pull requests are welcome. For major changes please open an issue first to discuss what you'd like to change. + +1. Fork the repo +2. Create a feature branch (`git checkout -b feature/your-feature`) +3. Commit your changes +4. Push to the branch and open a pull request diff --git a/plugins/folder_library/plugin.json b/plugins/folder_library/plugin.json new file mode 100644 index 0000000..02b8a93 --- /dev/null +++ b/plugins/folder_library/plugin.json @@ -0,0 +1,10 @@ +{ + "id": "folder_library", + "name": "Folder Library", + "version": "1.8.0", + "bundled": true, + "nav": { "label": "Folders", "screen": "plugin-folder_library" }, + "screen": "screen.html", + "script": "screen.js", + "routes": "routes.py" +} diff --git a/plugins/folder_library/routes.py b/plugins/folder_library/routes.py new file mode 100644 index 0000000..6163f9c --- /dev/null +++ b/plugins/folder_library/routes.py @@ -0,0 +1,440 @@ +""" +Folder Library plugin — routes.py + +Surfaces the DLC folder structure as a navigable tree and provides in-app +folder management (create / rename / delete) and song moves. Every filesystem +mutation is confined to DLC_DIR and validated against path traversal. +""" + +from pathlib import Path +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse +import shutil +import re + + +# ── Pure, testable helpers ───────────────────────────────────────────────── + +_UNSAFE_NAME_RE = re.compile(r'[\\/:*?"<>|]') + + +def _safe_name(name: str) -> bool: + """A single path segment is safe: no separators, no traversal dot-names, + no surrounding whitespace, no characters illegal across filesystems.""" + if not name or name.strip() != name: + return False + if _UNSAFE_NAME_RE.search(name): + return False + if name in (".", ".."): + return False + return True + + +def _safe_path(path_str: str) -> bool: + """A slash-separated path is safe iff every segment is a safe name.""" + if not path_str: + return False + return all(_safe_name(p) for p in path_str.split("/")) + + +def _is_within(root: Path, candidate: Path) -> bool: + """True iff ``candidate`` resolves to a location inside ``root`` (after + normalising ``..`` and symlinks). Containment backstop for file moves so a + crafted filename can't escape DLC_DIR even past the segment validator.""" + try: + candidate.resolve().relative_to(root.resolve()) + return True + except (ValueError, OSError): + return False + + +def _path_to_dir(root: Path, folder_path: str) -> Path: + """Resolve a slash-separated folder path relative to ``root``.""" + result = root + for part in folder_path.split("/"): + result = result / part + return result + + +def _load_is_loose_song(): + """The host's authoritative loose-folder predicate (lib/loosefolder.py), + imported lazily so the plugin still loads if it's ever unavailable. A + loose-folder song is a directory carrying audio + an arrangement XML rather + than a ``.sloppak`` bundle, so the plain suffix check below misses it.""" + try: + from loosefolder import is_loose_song + return is_loose_song + except Exception: + return None + + +_IS_LOOSE_SONG = _load_is_loose_song() + + +def _is_song(p: Path) -> bool: + """A song carrier is a ``.sloppak`` / ``.feedpak`` file or directory-form + bundle (extension on the leaf name), or a host-recognised loose-folder song + directory — so loose-folder charts surface in the tree like any other song + instead of being walked into as if they were ordinary folders.""" + if p.suffix.lower() in (".sloppak", ".feedpak"): + return True + if _IS_LOOSE_SONG is not None and p.is_dir(): + try: + return bool(_IS_LOOSE_SONG(p)) + except Exception: + return False + return False + + +def setup(app, context): + log = context["log"] + router = APIRouter(prefix="/api/plugins/folder_library") + + # ── Two-level cache ──────────────────────────────────────────────── + # _meta_cache — expensive extract_meta() results keyed by abs path + # (as_posix() string). Never cleared; keys are updated + # in-place when files are moved so the data stays valid. + # _cache — tree structure ("folders" / "root_songs"). Cleared on + # every mutation so the next /tree request rebuilds it — + # but that rebuild is now fast because _meta_cache is warm. + _cache = {} # "tree" → JSONResponse-ready dict + _meta_cache = {} # abs_posix_path → extracted meta (no filename/added) + + def _invalidate(): + """Clear the tree structure cache only. _meta_cache is preserved.""" + _cache.clear() + + def _dlc_root() -> Path | None: + try: + return Path(context["get_dlc_dir"]()) + except Exception: + return None + + def _scan_root(dlc: Path) -> Path: + sloppak = dlc / "sloppak" + return sloppak if sloppak.exists() else dlc + + def _meta(p: Path, dlc: Path) -> dict: + # filename and added are always computed fresh — they change when files move. + try: + filename = "/".join(p.relative_to(dlc).parts) + except ValueError: + filename = p.name + added = None + try: + added = p.stat().st_mtime + except Exception: + pass + + # Return cached extracted metadata if available. + cache_key = p.as_posix() + if cache_key in _meta_cache: + m = dict(_meta_cache[cache_key]) # shallow copy + m["filename"] = filename + m["added"] = added + return m + + # Cache miss — run the expensive extract. + m = {"title": None, "artist": None, "album": None, "duration": None, + "year": None, "tuning": None, "arrangements": [], "stems": [], "lyrics": False} + try: + raw = context["extract_meta"](p) + if raw: + m["title"] = raw.get("title") or raw.get("name") + m["artist"] = raw.get("artist") or raw.get("artistName") + m["album"] = raw.get("album") or raw.get("albumName") + m["duration"] = raw.get("duration") + m["year"] = raw.get("year") + m["tuning"] = raw.get("tuning") + + # arrangements — objects with a "name" key e.g. [{name:"Lead",...}, ...] + raw_arr = raw.get("arrangements") or [] + if isinstance(raw_arr, (list, tuple)): + m["arrangements"] = [ + a["name"] if isinstance(a, dict) else str(a) + for a in raw_arr + if (isinstance(a, dict) and "name" in a) or isinstance(a, str) + ] + + # stems — may also be objects with a "name" key, same as arrangements + raw_stems = raw.get("stems") or [] + for _key in ("stems", "stem_types", "available_stems", "stemTypes"): + _v = raw.get(_key) + if _v: + raw_stems = _v + break + if isinstance(raw_stems, (list, tuple)): + m["stems"] = [ + a["name"] if isinstance(a, dict) else str(a) + for a in raw_stems + if (isinstance(a, dict) and "name" in a) or isinstance(a, str) + ] + + # lyrics — try common key variants + for _key in ("lyrics", "hasLyrics", "has_lyrics", "lyric", "hasLyric"): + _val = raw.get(_key) + if _val is not None: + if isinstance(_val, str): + m["lyrics"] = _val.lower() not in ("", "false", "no", "0") + else: + m["lyrics"] = bool(_val) + break + except Exception as exc: + log.debug("meta failed for %s: %s", p.name, exc) + if not m["title"]: + m["title"] = p.stem + + _meta_cache[cache_key] = m # store without filename/added + result = dict(m) + result["filename"] = filename + result["added"] = added + return result + + def _scan_dir(path: Path, root: Path, dlc: Path) -> dict: + """Recursively scan a directory and return a folder node.""" + songs = [] + children = [] + try: + for entry in sorted(path.iterdir(), key=lambda p: p.name.lower()): + if entry.name.startswith("."): + continue + if _is_song(entry): + songs.append(_meta(entry, dlc)) + elif entry.is_dir(): + children.append(_scan_dir(entry, root, dlc)) + except PermissionError: + log.warning("permission denied: %s", path) + try: + rel = path.relative_to(root) + folder_path = "/".join(rel.parts) + except ValueError: + folder_path = path.name + return { + "name": path.name, + "path": folder_path, + "songs": songs, + "children": children, + } + + def _apply_tree_filters(tree, arrangements_has="", arrangements_lacks="", + stems_has="", stems_lacks="", has_lyrics="", tunings=""): + """Filter a cached tree dict by arrangement/stem/lyrics/tuning params. + The cache always holds the full unfiltered tree; this is applied per-request.""" + def _split(s): + return [x.strip().lower() for x in s.split(",") if x.strip()] if s else [] + + arr_has = _split(arrangements_has) + arr_lacks = _split(arrangements_lacks) + st_has = _split(stems_has) + st_lacks = _split(stems_lacks) + tun_set = set(_split(tunings)) + lyr = None if has_lyrics == "" else (has_lyrics == "1") + + if not any([arr_has, arr_lacks, st_has, st_lacks, tun_set, lyr is not None]): + return tree # no filters active — return as-is + + def _song_ok(s): + arrs = [a.lower() for a in (s.get("arrangements") or [])] + stms = [x.lower() for x in (s.get("stems") or [])] + if arr_has and not any(a in arrs for a in arr_has): return False + if arr_lacks and any(a in arrs for a in arr_lacks): return False + if st_has and not any(x in stms for x in st_has): return False + if st_lacks and any(x in stms for x in st_lacks): return False + if lyr is not None and bool(s.get("lyrics")) != lyr: return False + if tun_set and (s.get("tuning") or "").lower() not in tun_set: return False + return True + + def _filter_node(node): + return { + "name": node["name"], + "path": node["path"], + "songs": [s for s in node["songs"] if _song_ok(s)], + "children": [_filter_node(c) for c in node.get("children", [])], + } + + return { + "folders": [_filter_node(f) for f in tree["folders"]], + "root_songs": [s for s in tree["root_songs"] if _song_ok(s)], + } + + @router.get("/tree") + def get_tree( + arrangements_has: str = "", + arrangements_lacks: str = "", + stems_has: str = "", + stems_lacks: str = "", + has_lyrics: str = "", + tunings: str = "", + ): + if "tree" not in _cache: + dlc = _dlc_root() + if not dlc or not dlc.exists(): + return JSONResponse({"folders": [], "root_songs": [], + "error": "DLC directory not found"}) + root = _scan_root(dlc) + log.info("folder_library: scanning %s", root) + folders = [] + root_songs = [] + try: + for entry in sorted(root.iterdir(), key=lambda p: p.name.lower()): + if entry.name.startswith("."): + continue + if _is_song(entry): + root_songs.append(_meta(entry, dlc)) + elif entry.is_dir(): + folders.append(_scan_dir(entry, root, dlc)) + except PermissionError: + return JSONResponse({"folders": [], "root_songs": [], + "error": "Permission denied"}) + _cache["tree"] = {"folders": folders, "root_songs": root_songs} + + result = _apply_tree_filters( + _cache["tree"], arrangements_has, arrangements_lacks, + stems_has, stems_lacks, has_lyrics, tunings, + ) + return JSONResponse(result) + + @router.post("/folder/create") + async def create_folder(request: Request): + body = await request.json() + name = (body.get("name") or "").strip() + parent = (body.get("parent") or "").strip() + if not _safe_name(name): + return JSONResponse({"error": "Invalid folder name"}, status_code=400) + if parent and not _safe_path(parent): + return JSONResponse({"error": "Invalid parent path"}, status_code=400) + dlc = _dlc_root() + if not dlc: + return JSONResponse({"error": "DLC dir not found"}, status_code=500) + root = _scan_root(dlc) + parent_dir = _path_to_dir(root, parent) if parent else root + if parent and not parent_dir.exists(): + return JSONResponse({"error": "Parent folder not found"}, status_code=404) + target = parent_dir / name + if target.exists(): + return JSONResponse({"error": "Folder already exists"}, status_code=400) + try: + target.mkdir(parents=False) + _invalidate() + return JSONResponse({"ok": True}) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + + @router.post("/folder/rename") + async def rename_folder(request: Request): + body = await request.json() + old = (body.get("old") or "").strip() + new = (body.get("new") or "").strip() + if not _safe_path(old) or not _safe_name(new): + return JSONResponse({"error": "Invalid folder name"}, status_code=400) + dlc = _dlc_root() + if not dlc: + return JSONResponse({"error": "DLC dir not found"}, status_code=500) + root = _scan_root(dlc) + src = _path_to_dir(root, old) + dst = src.parent / new # rename within the same parent + if not src.exists(): + return JSONResponse({"error": "Folder not found"}, status_code=404) + if dst.exists(): + return JSONResponse({"error": "Name already taken"}, status_code=400) + try: + # Pre-compute meta cache key updates (keys change because the + # folder path changes — all files under src get a new prefix). + old_prefix = src.as_posix() + "/" + new_prefix = dst.as_posix() + "/" + meta_updates = { + key: new_prefix + key[len(old_prefix):] + for key in list(_meta_cache) + if key.startswith(old_prefix) + } + src.rename(dst) + _invalidate() + for old_key, new_key in meta_updates.items(): + if old_key in _meta_cache: + _meta_cache[new_key] = _meta_cache.pop(old_key) + return JSONResponse({"ok": True}) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + + @router.post("/folder/delete") + async def delete_folder(request: Request): + body = await request.json() + name = (body.get("name") or "").strip() + if not _safe_path(name): + return JSONResponse({"error": "Invalid folder path"}, status_code=400) + dlc = _dlc_root() + if not dlc: + return JSONResponse({"error": "DLC dir not found"}, status_code=500) + root = _scan_root(dlc) + target = _path_to_dir(root, name) + if not target.exists(): + return JSONResponse({"error": "Folder not found"}, status_code=404) + try: + # Relocate every song (at any depth) up to the scan root BEFORE + # removing the folder. Colliding filenames are de-duplicated so a + # name clash never leaves a song behind to be destroyed by rmtree + # (the folder is advertised as "moves its songs to Unsorted"). + for song_path in sorted(target.rglob("*")): + if not song_path.exists(): + continue # a parent song-dir was already relocated + if not _is_song(song_path): + continue + old_key = song_path.as_posix() + dest = root / song_path.name + if dest.exists(): + stem, suffix = song_path.stem, song_path.suffix + n = 1 + while dest.exists(): + dest = root / f"{stem} ({n}){suffix}" + n += 1 + song_path.rename(dest) + if old_key in _meta_cache: + _meta_cache[dest.as_posix()] = _meta_cache.pop(old_key) + shutil.rmtree(target) + _invalidate() + return JSONResponse({"ok": True}) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + + @router.post("/song/move") + async def move_song(request: Request): + body = await request.json() + filename = (body.get("filename") or "").strip() + dest_folder = (body.get("folder") or "").strip() + # Validate the source path like the folder ops, AND confirm it resolves + # inside DLC_DIR — without this a filename such as "../../etc/passwd" + # would be renamed (moved) into the served library and become readable. + if not filename or not _safe_path(filename): + return JSONResponse({"error": "Invalid filename"}, status_code=400) + dlc = _dlc_root() + if not dlc: + return JSONResponse({"error": "DLC dir not found"}, status_code=500) + src = dlc / Path(*filename.split("/")) + if not _is_within(dlc, src): + return JSONResponse({"error": "Invalid filename"}, status_code=400) + if not src.exists(): + return JSONResponse({"error": "Song not found"}, status_code=404) + root = _scan_root(dlc) + if dest_folder: + if not _safe_path(dest_folder): + return JSONResponse({"error": "Invalid folder path"}, status_code=400) + dst_dir = _path_to_dir(root, dest_folder) + if not dst_dir.exists(): + return JSONResponse({"error": "Destination folder not found"}, status_code=404) + else: + dst_dir = root + dst = dst_dir / src.name + if dst.exists(): + return JSONResponse({"error": "File already exists at destination"}, status_code=400) + try: + old_key = src.as_posix() + src.rename(dst) + if old_key in _meta_cache: + _meta_cache[dst.as_posix()] = _meta_cache.pop(old_key) + _invalidate() + return JSONResponse({"ok": True}) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + + app.include_router(router) + log.info("folder_library routes registered") diff --git a/plugins/folder_library/screen.html b/plugins/folder_library/screen.html new file mode 100644 index 0000000..7dc794a --- /dev/null +++ b/plugins/folder_library/screen.html @@ -0,0 +1,159 @@ + + + +
+ +

Folders

+ + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + + + + + + + + diff --git a/plugins/folder_library/screen.js b/plugins/folder_library/screen.js new file mode 100644 index 0000000..0a9fa0c --- /dev/null +++ b/plugins/folder_library/screen.js @@ -0,0 +1,1672 @@ +/* Folder Browser — screen.js + * Plain JS, global scope, IIFE. Follows feedBack plugin conventions. + * + * ONE shared implementation (`createFolderSurface`) parameterised by a + * "surface config", consumed by two thin adapters: + * • NAV adapter — the standalone "Folders" nav-screen in the classic (v2) + * UI. Owns its own toolbar + search (#fb-*), client-side filter panel and + * local sort, renders into #fb-tree. + * • LIB adapter — the folder VIEW embedded in the v3 Songs page. Uses host + * chrome (host search #v3-search/#lib-filter, host filter params, host + * sort), renders into #lib-folder-tree, injects a toolbar into + * #lib-folder-controls, and exposes window.folderLibrary = {load, unload}. + * + * Each surface is an independent factory instance with its own closure state, + * so the two never share mutable state even when both run on the same page + * (they do in classic v2, where #lib-folder-tree also exists). + */ +(function () { +'use strict'; + +const API = '/api/plugins/folder_library'; + +// ════════════════════════════════════════════════════════════════════════ +// Shared surface factory +// ════════════════════════════════════════════════════════════════════════ +function createFolderSurface(cfg) { + + // ── Safe localStorage helpers (cfg.storePrefix keeps surfaces separate) ─ + function _store(key, val) { + try { + if (val === undefined) return localStorage.getItem(cfg.storePrefix + key); + localStorage.setItem(cfg.storePrefix + key, val); + } catch (_) { return null; } + } + function _storeJSON(key, val) { + try { + if (val === undefined) return JSON.parse(localStorage.getItem(cfg.storePrefix + key) || 'null'); + localStorage.setItem(cfg.storePrefix + key, JSON.stringify(val)); + } catch (_) { return null; } + } + + // ── State ─────────────────────────────────────────────────────────── + let _tree = null; + let _loaded = false; + let _lastFilterParams = null; // params string used for the last /tree fetch + let _openFolders = new Set(_storeJSON('open') || []); + let _unsortedOpen = _store(cfg.unsortedKey) !== 'false'; + let _view = _store('view') || 'list'; // 'list' | 'grid' + let _sort = _store('sort') || 'default'; + let _sortDir = _store('sortDir') || 'asc'; + let _toolbarDone = false; + let _hoveredFolder = null; // { wrap, hdr, btnGroup } — only innermost folder is active + + // ── Core arrangement order (pinned to top of filter panel) ────────── + const _CORE_ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo']; + + // ── Client-side filter state (nav surface only) ───────────────────── + var _filtersRaw = _storeJSON('filters') || {}; + function _normFilterGroup(g) { + var out = {}; + for (var k in (g || {})) { + var v = g[k]; + out[k] = v === 'require' ? 'on' : v === 'any' ? 'off' : v; + } + return out; + } + let _filters = { + arrangements: _normFilterGroup(_filtersRaw.arrangements), + stems: _normFilterGroup(_filtersRaw.stems), + lyrics: (_filtersRaw.lyrics === 'require' || _filtersRaw.lyrics === 'on') ? 'on' + : (_filtersRaw.lyrics === 'exclude') ? 'exclude' : 'off', + tunings: _filtersRaw.tunings || [], + }; + + // ── DOM helpers ───────────────────────────────────────────────────── + function _el(id) { return document.getElementById(id); } + function _treeEl() { return document.getElementById(cfg.treeId); } + + // ── Force screen to have height (nav screen has no height set) ────── + function _fixHeight() { + const el = _el(cfg.screenId); + const nav = document.querySelector('nav'); + const navH = nav ? nav.offsetHeight : 64; + if (el) el.style.minHeight = (window.innerHeight - navH) + 'px'; + } + + // ── Close the nav plugin dropdown (sits at z-50 and blocks clicks) ── + function _closeDropdown() { + var dd = _el('plugin-dropdown'); + if (dd) dd.classList.add('hidden'); + } + + // ── Status (nav status bar only) ──────────────────────────────────── + // Gated by cfg.ownsStatus so the library surface never mutates the nav + // screen's shared #fb-status element when both instances live on one page. + function _status(msg, isErr) { + if (!cfg.ownsStatus) return; + const el = _el('fb-status'); + if (!el) return; + el.textContent = msg || ''; + el.className = 'text-xs ml-1 ' + (isErr ? 'text-red-400' : 'text-gray-500'); + } + + // ── API helper ────────────────────────────────────────────────────── + async function _api(path, body) { + const opts = body + ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } + : {}; + const res = await fetch(cfg.apiBase + path, opts); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Request failed'); + return data; + } + + // ── Search value (read live from the surface's search input) ──────── + function _query() { + var el = cfg.getSearchEl ? cfg.getSearchEl() : null; + return el ? el.value.trim() : ''; + } + + // ── Flat list of every song in the tree (root + all nested folders) ─ + function _allSongs() { + if (!_tree) return []; + var result = _tree.root_songs.slice(); + function _collectFolder(f) { + f.songs.forEach(function (s) { result.push(s); }); + (f.children || []).forEach(_collectFolder); + } + _tree.folders.forEach(_collectFolder); + return result; + } + + // ── Dynamic arrangement / stem discovery (filter panel) ───────────── + function _getArrangements() { + var counts = {}; + _allSongs().forEach(function (s) { + (s.arrangements || []).forEach(function (a) { counts[a] = (counts[a] || 0) + 1; }); + }); + return Object.keys(counts).sort(function (a, b) { return (counts[b] - counts[a]) || a.localeCompare(b); }); + } + function _getStems() { + var counts = {}; + _allSongs().forEach(function (s) { + (s.stems || []).forEach(function (st) { counts[st] = (counts[st] || 0) + 1; }); + }); + return Object.keys(counts).sort(function (a, b) { return (counts[b] - counts[a]) || a.localeCompare(b); }); + } + function _getAvailableFilters() { + var out = { arrangements: false, stems: false, lyrics: false, tuning: false }; + _allSongs().forEach(function (s) { + if ((s.arrangements || []).length) out.arrangements = true; + if ((s.stems || []).length) out.stems = true; + if (s.lyrics) out.lyrics = true; + if (s.tuning) out.tuning = true; + }); + return out; + } + function _getTunings() { + var counts = {}; + _allSongs().forEach(function (s) { + var t = s.tuning ? String(s.tuning).trim() : ''; + if (t) counts[t] = (counts[t] || 0) + 1; + }); + return Object.keys(counts) + .sort(function (a, b) { return a.localeCompare(b); }) + .map(function (t) { return { tuning: t, count: counts[t] }; }); + } + + // ── Fetch tree ────────────────────────────────────────────────────── + async function _load(force) { + // Lib surface owns a couple of host-chrome tweaks on entry. + if (cfg.searchInputId) { + var fe = _el(cfg.searchInputId); + if (fe) fe.style.maxWidth = '320px'; + } + if (cfg.countId) { + var ce0 = _el(cfg.countId); + if (ce0) ce0.textContent = ''; + } + + var params = cfg.getFilterParams ? cfg.getFilterParams() : ''; + if (!force && _loaded && _tree && params === _lastFilterParams) { + if (cfg.injectToolbar) _injectToolbar(); + _render(); + return; + } + + _status('Loading…'); + var treeEl = _treeEl(); + if (!cfg.ownsStatus && treeEl) { + treeEl.innerHTML = '
Loading folders…
'; + } + try { + var url = '/tree' + (params ? '?' + params : ''); + var data = await _api(url); + if (data.error) { + if (cfg.ownsStatus) _status('⚠ ' + data.error, true); + else if (treeEl) { treeEl.innerHTML = ''; var _ed = document.createElement('div'); _ed.style.cssText = 'padding:48px;text-align:center;color:#ef4444;font-size:13px;'; _ed.textContent = '⚠ ' + data.error; treeEl.appendChild(_ed); } + return; + } + _tree = data; + _loaded = true; + _lastFilterParams = params; + _status(''); + // Lib auto-expands top-level folders on first visit (empty open set). + if (cfg.autoExpandTop && _openFolders.size === 0 && data.folders.length) { + data.folders.forEach(function (f) { _openFolders.add(f.path); }); + _storeJSON('open', [..._openFolders]); + } + if (cfg.injectToolbar) _injectToolbar(); + _render(); + // Rebuild filter panel if it's open so tuning list reflects new data. + if (cfg.ownsFilterPanel) { + var fp = _el('fb-filter-panel'); + if (fp && fp.style.display !== 'none') _buildFilterPanel(); + } + } catch (err) { + if (cfg.ownsStatus) _status('Load failed: ' + err.message, true); + else if (treeEl) { treeEl.innerHTML = ''; var _ed = document.createElement('div'); _ed.style.cssText = 'padding:48px;text-align:center;color:#ef4444;font-size:13px;'; _ed.textContent = '⚠ Failed to load: ' + err.message; treeEl.appendChild(_ed); } + } + } + + // ── Filtered tree ─────────────────────────────────────────────────── + // Search always applies. Nav additionally applies its client-side filter + // panel; lib additionally narrows by host artist/album (server already + // applied arrangement/stem/tuning filters via /tree params). + function _filtered() { + if (!_tree) return { folders: [], root_songs: [] }; + var q = _query().toLowerCase(); + var artist = cfg.getHostArtist ? cfg.getHostArtist() : ''; + var album = cfg.getHostAlbum ? cfg.getHostAlbum() : ''; + var hasClientFilters = cfg.ownsFilterPanel && _activeFilterCount() > 0; + if (!q && !artist && !album && !hasClientFilters) return _tree; + function _keep(s) { + if (artist && (s.artist || '') !== artist) return false; + if (album && (s.album || '') !== album) return false; + if (q && !( + (s.title || '').toLowerCase().includes(q) || + (s.artist || '').toLowerCase().includes(q) || + (s.album || '').toLowerCase().includes(q) || + s.filename.toLowerCase().includes(q) + )) return false; + if (hasClientFilters && !_matchFilters(s)) return false; + return true; + } + function _filterFolder(f) { + var songs = f.songs.filter(_keep); + var children = (f.children || []).map(_filterFolder).filter(function (c) { + return c.songs.length || (c.children || []).length; + }); + return { name: f.name, path: f.path, songs: songs, children: children }; + } + var folders = _tree.folders.map(_filterFolder).filter(function (f) { + return f.songs.length || (f.children || []).length; + }); + return { folders: folders, root_songs: _tree.root_songs.filter(_keep) }; + } + + // ── Client-side filter helpers (nav surface) ──────────────────────── + function _saveFilters() { + _storeJSON('filters', _filters); + } + function _activeFilterCount() { + var n = 0; + var arrVals = _filters.arrangements || {}; + for (var a in arrVals) { if (arrVals[a] === 'on' || arrVals[a] === 'exclude') n++; } + var stemVals = _filters.stems || {}; + for (var s in stemVals) { if (stemVals[s] === 'on' || stemVals[s] === 'exclude') n++; } + if (_filters.lyrics === 'on' || _filters.lyrics === 'exclude') n++; + n += (_filters.tunings || []).length; + return n; + } + function _matchFilters(song) { + // Arrangements — include uses OR, exclude uses AND. + var arrF = _filters.arrangements || {}; + var songArr = song.arrangements || []; + var onArr = Object.keys(arrF).filter(function (a) { return arrF[a] === 'on'; }); + if (onArr.length && !onArr.some(function (a) { return songArr.indexOf(a) !== -1; })) return false; + for (var a in arrF) { + if (arrF[a] === 'exclude' && songArr.indexOf(a) !== -1) return false; + } + // Stems — same OR-include / AND-exclude logic. + var stemsF = _filters.stems || {}; + var songStems = song.stems || []; + var onStems = Object.keys(stemsF).filter(function (s) { return stemsF[s] === 'on'; }); + if (onStems.length && !onStems.some(function (s) { return songStems.indexOf(s) !== -1; })) return false; + for (var s in stemsF) { + if (stemsF[s] === 'exclude' && songStems.indexOf(s) !== -1) return false; + } + if (_filters.lyrics === 'on' && !song.lyrics) return false; + if (_filters.lyrics === 'exclude' && song.lyrics) return false; + var tunings = _filters.tunings || []; + if (tunings.length) { + var t = (song.tuning || '').trim(); + if (!t || tunings.indexOf(t) === -1) return false; + } + return true; + } + + // Split pill: left zone = include, right zone = exclude. state: 'off'|'on'|'exclude' + function _makeSplitPill(label, state, onChange) { + var pill = document.createElement('div'); + pill.style.cssText = 'display:inline-flex; border-radius:20px; border:1px solid; overflow:hidden;'; + var incBtn = document.createElement('button'); + incBtn.style.cssText = 'padding:4px 10px; background:none; border:none; border-right:1px solid; font-size:12px; cursor:pointer; white-space:nowrap;'; + incBtn.textContent = label; + var excBtn = document.createElement('button'); + excBtn.style.cssText = 'padding:4px 8px; background:none; border:none; font-size:11px; cursor:pointer; line-height:1;'; + excBtn.title = 'Exclude'; + excBtn.textContent = '✕'; + function _apply() { + if (state === 'on') { + pill.style.borderColor = '#2563eb'; + incBtn.style.background = '#1d4ed8'; + incBtn.style.color = '#fff'; + incBtn.style.borderRightColor = '#3b82f6'; + excBtn.style.background = '#1d4ed8'; + excBtn.style.color = 'rgba(255,255,255,0.45)'; + } else if (state === 'exclude') { + pill.style.borderColor = '#991b1b'; + incBtn.style.background = 'transparent'; + incBtn.style.color = '#fca5a5'; + incBtn.style.borderRightColor = '#7f1d1d'; + excBtn.style.background = 'transparent'; + excBtn.style.color = '#ef4444'; + } else { + pill.style.borderColor = '#374151'; + incBtn.style.background = 'transparent'; + incBtn.style.color = '#6b7280'; + incBtn.style.borderRightColor = '#374151'; + excBtn.style.background = 'transparent'; + excBtn.style.color = '#4b5563'; + } + } + _apply(); + incBtn.addEventListener('click', function () { + state = (state === 'on') ? 'off' : 'on'; + _apply(); onChange(state); + }); + excBtn.addEventListener('click', function () { + state = (state === 'exclude') ? 'off' : 'exclude'; + _apply(); onChange(state); + }); + pill.appendChild(incBtn); + pill.appendChild(excBtn); + return pill; + } + + // ── Song date info (year + date added) — hover reveal (nav) ───────── + function _buildSongDateInfo(song) { + var parts = []; + if (song.year != null && song.year !== '') parts.push(String(song.year)); + if (song.added) { + var d = new Date(song.added * 1000); + parts.push(d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })); + } + if (!parts.length) return null; + var el = document.createElement('div'); + el.style.cssText = 'font-size:11px; font-weight:500; color:#cbd5e1; ' + + 'max-height:0; opacity:0; overflow:hidden; margin-top:0; ' + + 'transition:max-height 0.2s ease, opacity 0.15s, margin-top 0.15s;'; + el.textContent = parts.join(' · '); + return el; + } + + // ── Song metadata badges (visible on hover; click toggles a filter) ─ + function _badge(text, active, type) { + var b = document.createElement('span'); + var _typeColors = { + arrangement: { border: '#92400e', color: '#fcd34d' }, + stem: { border: '#5b21b6', color: '#c4b5fd' }, + lyrics: { border: '#9f1239', color: '#fda4af' }, + tuning: { border: '#0f766e', color: '#5eead4' }, + }; + var tc = (!active && type) ? (_typeColors[type] || null) : null; + b.style.cssText = 'display:inline-block; padding:1px 6px; border-radius:3px; ' + + 'font-size:10px; font-weight:500; white-space:nowrap; cursor:pointer; ' + + 'border:1px solid ' + (active ? '#3b82f6' : (tc ? tc.border : '#334155')) + '; ' + + 'background:' + (active ? '#1d4ed8' : 'transparent') + '; ' + + 'color:' + (active ? '#fff' : (tc ? tc.color : '#cbd5e1')) + ';'; + b.textContent = text; + return b; + } + function _buildSongBadges(song) { + var wrap = document.createElement('div'); + wrap.style.cssText = 'display:flex; flex-wrap:wrap; gap:3px; ' + + 'max-height:0; opacity:0; overflow:hidden; margin-top:0; ' + + 'transition:max-height 0.2s ease, opacity 0.15s, margin-top 0.15s;'; + var any = false; + var _seenArr = {}; + var _seenStem = {}; + (song.arrangements || []).forEach(function (a) { + if (_seenArr[a]) return; _seenArr[a] = true; + var active = ((_filters.arrangements || {})[a] === 'on'); + var b = _badge(a, active, 'arrangement'); + b.addEventListener('click', function (e) { + e.stopPropagation(); + if (!_filters.arrangements) _filters.arrangements = {}; + _filters.arrangements[a] = active ? 'off' : 'on'; + _saveFilters(); _updateFilterBadge(); _render(); + }); + wrap.appendChild(b); any = true; + }); + (song.stems || []).forEach(function (s) { + if (_seenStem[s]) return; _seenStem[s] = true; + var active = ((_filters.stems || {})[s] === 'on'); + var b = _badge(s, active, 'stem'); + b.addEventListener('click', function (e) { + e.stopPropagation(); + if (!_filters.stems) _filters.stems = {}; + _filters.stems[s] = active ? 'off' : 'on'; + _saveFilters(); _updateFilterBadge(); _render(); + }); + wrap.appendChild(b); any = true; + }); + if (song.lyrics) { + var lyrActive = (_filters.lyrics === 'on'); + var lb = _badge('♪ Lyrics', lyrActive, 'lyrics'); + lb.addEventListener('click', function (e) { + e.stopPropagation(); + _filters.lyrics = lyrActive ? 'off' : 'on'; + _saveFilters(); _updateFilterBadge(); _render(); + }); + wrap.appendChild(lb); any = true; + } + if (song.tuning) { + var t = song.tuning.trim(); + var tunActive = (_filters.tunings || []).indexOf(t) !== -1; + var tb = _badge(t, tunActive, 'tuning'); + tb.addEventListener('click', function (e) { + e.stopPropagation(); + if (!_filters.tunings) _filters.tunings = []; + var idx = _filters.tunings.indexOf(t); + if (idx !== -1) _filters.tunings.splice(idx, 1); + else _filters.tunings.push(t); + _saveFilters(); _updateFilterBadge(); _render(); + }); + wrap.appendChild(tb); any = true; + } + return any ? wrap : null; + } + function _revealBadges(el) { + el.style.maxHeight = '120px'; + el.style.opacity = '1'; + el.style.marginTop = '4px'; + } + function _hideBadges(el) { + el.style.maxHeight = '0'; + el.style.opacity = '0'; + el.style.marginTop = '0'; + } + function _updateFilterBadge() { + var badge = _el('fb-filter-badge'); + if (!badge) return; + var n = _activeFilterCount(); + badge.style.display = n ? 'block' : 'none'; + badge.textContent = String(n); + } + + // ── Filter panel sections (nav) ───────────────────────────────────── + function _makePillSection(sectionTitle, items, filterKey, extraItems) { + var section = document.createElement('div'); + section.style.marginBottom = '20px'; + var hdr = document.createElement('div'); + hdr.style.cssText = 'font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:#6b7280; margin-bottom:8px;'; + hdr.textContent = sectionTitle; + section.appendChild(hdr); + var pills = document.createElement('div'); + pills.style.cssText = 'display:flex; flex-wrap:wrap; gap:6px;'; + function _addPill(item) { + var state = ((_filters[filterKey] || {})[item]) || 'off'; + pills.appendChild(_makeSplitPill(item, state, function (next) { + if (!_filters[filterKey]) _filters[filterKey] = {}; + _filters[filterKey][item] = next; + _saveFilters(); _updateFilterBadge(); _render(); + })); + } + items.forEach(_addPill); + if (extraItems && extraItems.length) { + var sep = document.createElement('div'); + sep.style.cssText = 'width:100%; height:1px; background:#1f2937; margin:4px 0 2px;'; + pills.appendChild(sep); + extraItems.forEach(_addPill); + } + section.appendChild(pills); + return section; + } + function _makeLyricsSection() { + var section = document.createElement('div'); + section.style.marginBottom = '20px'; + var hdr = document.createElement('div'); + hdr.style.cssText = 'font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:#6b7280; margin-bottom:8px;'; + hdr.textContent = 'LYRICS'; + section.appendChild(hdr); + var state = _filters.lyrics || 'off'; + section.appendChild(_makeSplitPill('Lyrics', state, function (next) { + _filters.lyrics = next; + _saveFilters(); _updateFilterBadge(); _render(); + })); + return section; + } + function _makeTuningSection() { + var section = document.createElement('div'); + section.style.marginBottom = '20px'; + var tunings = _getTunings(); + if (!tunings.length) return section; + var titleRow = document.createElement('div'); + titleRow.style.cssText = 'display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;'; + var titleEl = document.createElement('div'); + titleEl.style.cssText = 'font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:#6b7280;'; + titleEl.textContent = 'TUNING'; + var allLbl = document.createElement('span'); + allLbl.style.cssText = 'font-size:11px; color:#6b7280;'; + function _updateAllLbl() { + var n = (_filters.tunings || []).length; + allLbl.textContent = n ? n + ' selected' : 'All tunings'; + } + _updateAllLbl(); + titleRow.appendChild(titleEl); + titleRow.appendChild(allLbl); + section.appendChild(titleRow); + var list = document.createElement('div'); + list.style.cssText = 'display:flex; flex-direction:column; gap:2px;'; + tunings.forEach(function (entry) { + var row = document.createElement('label'); + row.style.cssText = 'display:flex; align-items:center; gap:8px; padding:5px 4px; cursor:pointer; border-radius:4px;'; + row.addEventListener('mouseenter', function () { row.style.background = '#111827'; }); + row.addEventListener('mouseleave', function () { row.style.background = ''; }); + var cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.style.cssText = 'width:14px; height:14px; accent-color:#3b82f6; cursor:pointer; flex-shrink:0;'; + cb.checked = (_filters.tunings || []).indexOf(entry.tuning) !== -1; + var lbl = document.createElement('span'); + lbl.style.cssText = 'flex:1; font-size:13px; color:#d1d5db;'; + lbl.textContent = entry.tuning; + var cnt = document.createElement('span'); + cnt.style.cssText = 'font-size:12px; color:#6b7280; font-variant-numeric:tabular-nums;'; + cnt.textContent = entry.count; + cb.addEventListener('change', function () { + if (!_filters.tunings) _filters.tunings = []; + if (cb.checked) { + if (_filters.tunings.indexOf(entry.tuning) === -1) + _filters.tunings.push(entry.tuning); + } else { + _filters.tunings = _filters.tunings.filter(function (t) { return t !== entry.tuning; }); + } + _saveFilters(); _updateAllLbl(); _updateFilterBadge(); _render(); + }); + row.appendChild(cb); + row.appendChild(lbl); + row.appendChild(cnt); + list.appendChild(row); + }); + section.appendChild(list); + return section; + } + + // ── Filter panel open / close (nav) ───────────────────────────────── + function _buildFilterPanel() { + var panel = _el('fb-filter-panel'); + if (!panel) return; + panel.innerHTML = ''; + var hdr = document.createElement('div'); + hdr.style.cssText = 'display:flex; align-items:center; justify-content:space-between; padding:14px 20px; border-bottom:1px solid #1f2937; flex-shrink:0;'; + var titleEl = document.createElement('span'); + titleEl.style.cssText = 'font-size:15px; font-weight:600; color:#e5e7eb;'; + titleEl.textContent = 'Filters'; + var closeBtn = document.createElement('button'); + closeBtn.style.cssText = 'padding:4px; color:#6b7280; background:none; border:none; cursor:pointer; border-radius:4px;'; + closeBtn.innerHTML = ''; + closeBtn.addEventListener('click', _closeFilterPanel); + hdr.appendChild(titleEl); + hdr.appendChild(closeBtn); + panel.appendChild(hdr); + var content = document.createElement('div'); + content.style.cssText = 'overflow-y:auto; flex:1; padding:16px 20px;'; + var arrangements = _getArrangements(); + var stems = _getStems(); + var avail = _getAvailableFilters(); + if (arrangements.length) { + var coreArr = _CORE_ARRANGEMENTS.filter(function (a) { return arrangements.indexOf(a) !== -1; }); + var otherArr = arrangements.filter(function (a) { return _CORE_ARRANGEMENTS.indexOf(a) === -1; }); + content.appendChild(_makePillSection('ARRANGEMENTS', + coreArr.length ? coreArr : arrangements, + 'arrangements', + coreArr.length ? otherArr : [] + )); + } + if (stems.length) content.appendChild(_makePillSection('STEMS (sloppak)', stems, 'stems')); + if (avail.lyrics) content.appendChild(_makeLyricsSection()); + if (avail.tuning) content.appendChild(_makeTuningSection()); + panel.appendChild(content); + var footer = document.createElement('div'); + footer.style.cssText = 'display:flex; align-items:center; justify-content:space-between; padding:14px 20px; border-top:1px solid #1f2937; flex-shrink:0;'; + var clearBtn = document.createElement('button'); + clearBtn.style.cssText = 'font-size:13px; color:#6b7280; background:none; border:none; cursor:pointer; padding:0;'; + clearBtn.textContent = 'Clear all'; + clearBtn.addEventListener('click', function () { + _filters = { arrangements: {}, stems: {}, lyrics: 'off', tunings: [] }; + _saveFilters(); _updateFilterBadge(); _render(); + _buildFilterPanel(); + }); + var doneBtn = document.createElement('button'); + doneBtn.style.cssText = 'padding:6px 20px; border-radius:6px; border:none; background:#3b82f6; color:#fff; font-size:13px; cursor:pointer; font-weight:500;'; + doneBtn.textContent = 'Done'; + doneBtn.addEventListener('click', _closeFilterPanel); + footer.appendChild(clearBtn); + footer.appendChild(doneBtn); + panel.appendChild(footer); + } + function _openFilterPanel() { + _buildFilterPanel(); + var panel = _el('fb-filter-panel'); + var backdrop = _el('fb-filter-backdrop'); + if (panel) panel.style.display = 'flex'; + if (backdrop) backdrop.style.display = 'block'; + } + function _closeFilterPanel() { + var panel = _el('fb-filter-panel'); + var backdrop = _el('fb-filter-backdrop'); + if (panel) panel.style.display = 'none'; + if (backdrop) backdrop.style.display = 'none'; + } + + // ── Sort helper ───────────────────────────────────────────────────── + function _sortSongs(songs) { + if (cfg.ownsSort) { + // Nav: local sort state (#fb-sort + direction toggle). + if (_sort === 'default') return songs; + var arr = songs.slice(); + if (_sort === 'title') { + arr.sort(function (a, b) { return (a.title || a.filename).localeCompare(b.title || b.filename); }); + } else if (_sort === 'artist') { + arr.sort(function (a, b) { return (a.artist || '').localeCompare(b.artist || ''); }); + } else if (_sort === 'duration') { + arr.sort(function (a, b) { return (a.duration || 0) - (b.duration || 0); }); + } else if (_sort === 'year') { + arr.sort(function (a, b) { return (a.year || 0) - (b.year || 0); }); + } else if (_sort === 'tuning') { + arr.sort(function (a, b) { return (a.tuning || '').localeCompare(b.tuning || ''); }); + } else if (_sort === 'added') { + arr.sort(function (a, b) { return (a.added || 0) - (b.added || 0); }); + } + if (_sortDir === 'desc') arr.reverse(); + return arr; + } + // Lib: read host sort vocabulary (#lib-sort / #v3-songs-sort). + var v = cfg.getHostSort ? cfg.getHostSort() : ''; + if (!v) return songs; + var larr = songs.slice(); + if (v === 'artist' || v === 'artist-desc') { + larr.sort(function (a, b) { return (a.artist || '').localeCompare(b.artist || ''); }); + if (v === 'artist-desc') larr.reverse(); + } else if (v === 'title' || v === 'title-desc') { + larr.sort(function (a, b) { return (a.title || a.filename).localeCompare(b.title || b.filename); }); + if (v === 'title-desc') larr.reverse(); + } else if (v === 'recent') { + larr.sort(function (a, b) { return (b.added || 0) - (a.added || 0); }); + } else if (v === 'year-desc') { + larr.sort(function (a, b) { return (b.year || 0) - (a.year || 0); }); + } else if (v === 'year') { + larr.sort(function (a, b) { return (a.year || 0) - (b.year || 0); }); + } else if (v === 'tuning') { + larr.sort(function (a, b) { return (a.tuning || '').localeCompare(b.tuning || ''); }); + } + return larr; + } + + // ── Custom modal (Electron blocks prompt/confirm) ─────────────────── + // Self-contained: builds its own DOM and keeps element references in + // closure (no global ids), so two surface instances never collide. + var _modalEl = null; + var _modalParts = null; + function _getModal() { + if (_modalEl && document.body.contains(_modalEl)) return _modalParts; + _modalEl = document.createElement('div'); + _modalEl.style.cssText = 'display:none; position:fixed; inset:0; z-index:9999; align-items:center; justify-content:center; background:rgba(0,0,0,0.6);'; + var box = document.createElement('div'); + box.style.cssText = 'background:#1f2937; border:1px solid #374151; border-radius:10px; padding:24px; min-width:320px; max-width:480px; box-shadow:0 8px 40px rgba(0,0,0,0.7);'; + var msgEl = document.createElement('div'); + msgEl.style.cssText = 'color:#e5e7eb; font-size:14px; white-space:pre-wrap; margin-bottom:16px; line-height:1.5;'; + var inp = document.createElement('input'); + inp.type = 'text'; + inp.style.cssText = 'display:none; width:100%; background:#111827; border:1px solid #4b5563; border-radius:6px; padding:8px 12px; color:#e5e7eb; font-size:14px; outline:none; box-sizing:border-box; margin-bottom:16px;'; + var btns = document.createElement('div'); + btns.style.cssText = 'display:flex; justify-content:flex-end; gap:8px;'; + var cancelBtn = document.createElement('button'); + cancelBtn.style.cssText = 'padding:7px 18px; border-radius:6px; border:1px solid #374151; background:transparent; color:#9ca3af; font-size:13px; cursor:pointer;'; + cancelBtn.textContent = 'Cancel'; + var okBtn = document.createElement('button'); + okBtn.style.cssText = 'padding:7px 18px; border-radius:6px; border:none; background:#3b82f6; color:#fff; font-size:13px; font-weight:500; cursor:pointer;'; + okBtn.textContent = 'OK'; + btns.appendChild(cancelBtn); btns.appendChild(okBtn); + box.appendChild(msgEl); box.appendChild(inp); box.appendChild(btns); + _modalEl.appendChild(box); + document.body.appendChild(_modalEl); + _modalParts = { modal: _modalEl, msgEl: msgEl, input: inp, okBtn: okBtn, cancel: cancelBtn }; + return _modalParts; + } + function _showModal(message, withInput, defaultVal) { + return new Promise(function (resolve) { + var p = _getModal(); + p.msgEl.textContent = message; + if (withInput) { + p.input.style.display = 'block'; + p.input.value = defaultVal || ''; + setTimeout(function () { p.input.focus(); p.input.select(); }, 50); + } else { + p.input.style.display = 'none'; + } + p.modal.style.display = 'flex'; + function _done(val) { + p.modal.style.display = 'none'; + p.okBtn.removeEventListener('click', _ok); + p.cancel.removeEventListener('click', _cxl); + p.input.removeEventListener('keydown', _key); + resolve(val); + } + function _ok() { _done(withInput ? p.input.value.trim() : true); } + function _cxl() { _done(null); } + function _key(e) { + if (e.key === 'Enter') { e.preventDefault(); _ok(); } + if (e.key === 'Escape') { e.preventDefault(); _cxl(); } + } + p.okBtn.addEventListener('click', _ok); + p.cancel.addEventListener('click', _cxl); + if (withInput) p.input.addEventListener('keydown', _key); + }); + } + function _confirm(msg) { return _showModal(msg, false, ''); } + function _prompt(msg, def) { return _showModal(msg, true, def || ''); } + + // ── Song card (grid view) ─────────────────────────────────────────── + function _songCard(song, folderName) { + var card = document.createElement('div'); + card.className = 'flex flex-col rounded-lg overflow-hidden cursor-pointer group transition-transform duration-100 hover:scale-105'; + card.style.background = '#1a1d2e'; + card.dataset.filename = song.filename; + + var artWrap = document.createElement('div'); + artWrap.style.cssText = 'position:relative; width:100%; padding-bottom:100%; background:#111827; overflow:hidden;'; + var img = document.createElement('img'); + img.style.cssText = 'position:absolute; inset:0; width:100%; height:100%; object-fit:cover;'; + img.alt = ''; img.loading = 'lazy'; + img.src = '/api/song/' + song.filename.split('/').map(encodeURIComponent).join('/') + '/art'; + var ph = document.createElement('div'); + ph.style.cssText = 'position:absolute; inset:0; display:flex; align-items:center; justify-content:center;'; + ph.innerHTML = ''; + img.addEventListener('error', function () { img.style.display = 'none'; ph.style.display = 'flex'; }); + img.addEventListener('load', function () { ph.style.display = 'none'; }); + artWrap.appendChild(ph); artWrap.appendChild(img); + + if (song.duration != null) { + var durB = document.createElement('span'); + durB.style.cssText = 'position:absolute; bottom:6px; right:6px; padding:2px 6px; border-radius:4px; font-size:11px; font-weight:600; color:#e5e7eb; background:rgba(0,0,0,0.7);'; + var m0 = Math.floor(song.duration / 60), s0 = String(Math.floor(song.duration % 60)).padStart(2, '0'); + durB.textContent = m0 + ':' + s0; + artWrap.appendChild(durB); + } + + var moveBtn = document.createElement('button'); + moveBtn.style.cssText = 'position:absolute; top:6px; right:6px; padding:4px; border-radius:4px; background:rgba(0,0,0,0.6); color:#9ca3af; border:none; cursor:pointer; display:none;'; + moveBtn.title = 'Move to folder…'; + moveBtn.innerHTML = ''; + card.addEventListener('mouseenter', function () { moveBtn.style.display = 'block'; }); + card.addEventListener('mouseleave', function () { moveBtn.style.display = 'none'; }); + moveBtn.addEventListener('click', function (e) { e.stopPropagation(); _moveSong(song, folderName); }); + artWrap.appendChild(moveBtn); + + var meta = document.createElement('div'); + meta.style.cssText = 'padding:8px 10px 10px; flex:1; min-width:0;'; + var title = document.createElement('div'); + title.style.cssText = 'font-size:13px; font-weight:600; color:#e5e7eb; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;'; + title.textContent = song.title || song.filename; + var sub = document.createElement('div'); + sub.style.cssText = 'font-size:11px; color:#6b7280; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-top:2px;'; + sub.textContent = [song.artist, song.album].filter(Boolean).join(' — ') || ''; + meta.appendChild(title); meta.appendChild(sub); + + if (cfg.songBadges) { + var cardBadges = _buildSongBadges(song); + if (cardBadges) { + meta.appendChild(cardBadges); + card.addEventListener('mouseenter', function () { _revealBadges(cardBadges); }); + card.addEventListener('mouseleave', function () { _hideBadges(cardBadges); }); + } + var cardDateInfo = _buildSongDateInfo(song); + if (cardDateInfo) { + meta.appendChild(cardDateInfo); + card.addEventListener('mouseenter', function () { _revealBadges(cardDateInfo); }); + card.addEventListener('mouseleave', function () { _hideBadges(cardDateInfo); }); + } + } + + card.appendChild(artWrap); card.appendChild(meta); + card.addEventListener('click', function () { + if (typeof window.playSong === 'function') window.playSong(song.filename); + }); + _makeDraggable(card, song, folderName); + return card; + } + + // ── Song row (list view) ──────────────────────────────────────────── + function _songRow(song, folderName) { + var row = document.createElement('div'); + row.className = 'flex items-center gap-3 px-3 py-2 rounded cursor-pointer hover:bg-dark-500 group transition-colors duration-100'; + row.dataset.filename = song.filename; + + var thumb = document.createElement('div'); + thumb.style.cssText = 'width:36px; height:36px; border-radius:4px; overflow:hidden; background:#111827; flex-shrink:0; position:relative;'; + var tImg = document.createElement('img'); + tImg.loading = 'lazy'; + tImg.src = '/api/song/' + song.filename.split('/').map(encodeURIComponent).join('/') + '/art'; + tImg.alt = ''; tImg.style.cssText = 'width:100%; height:100%; object-fit:cover;'; + var tPh = document.createElement('div'); + tPh.style.cssText = 'position:absolute; inset:0; display:flex; align-items:center; justify-content:center;'; + tPh.innerHTML = ''; + tImg.addEventListener('error', function () { tImg.style.display = 'none'; tPh.style.display = 'flex'; }); + tImg.addEventListener('load', function () { tPh.style.display = 'none'; }); + thumb.appendChild(tPh); thumb.appendChild(tImg); + + var meta = document.createElement('div'); + meta.className = 'flex-1 min-w-0'; + var title = document.createElement('div'); + title.className = 'text-gray-200 truncate group-hover:text-white'; + title.style.cssText = 'font-size:13px; font-weight:600;'; + title.textContent = song.title || song.filename; + var sub = document.createElement('div'); + sub.className = 'text-gray-500 truncate'; sub.style.fontSize = '11px'; + sub.textContent = [song.artist, song.album].filter(Boolean).join(' — ') || ''; + meta.appendChild(title); meta.appendChild(sub); + if (cfg.songBadges) { + var rowBadges = _buildSongBadges(song); + if (rowBadges) { + meta.appendChild(rowBadges); + row.addEventListener('mouseenter', function () { _revealBadges(rowBadges); }); + row.addEventListener('mouseleave', function () { _hideBadges(rowBadges); }); + } + var rowDateInfo = _buildSongDateInfo(song); + if (rowDateInfo) { + meta.appendChild(rowDateInfo); + row.addEventListener('mouseenter', function () { _revealBadges(rowDateInfo); }); + row.addEventListener('mouseleave', function () { _hideBadges(rowDateInfo); }); + } + } + + var icon = document.createElement('span'); + icon.className = 'shrink-0 w-4 h-4 text-dark-400 group-hover:text-blue-400 transition-colors opacity-0 group-hover:opacity-100'; + icon.innerHTML = ''; + + var dur = document.createElement('span'); + dur.className = 'shrink-0 text-xs text-gray-600 tabular-nums'; + if (song.duration != null) { + var m1 = Math.floor(song.duration / 60), s1 = String(Math.floor(song.duration % 60)).padStart(2, '0'); + dur.textContent = m1 + ':' + s1; + } + + var moveBtn = document.createElement('button'); + moveBtn.className = 'shrink-0 p-1 rounded text-gray-600 hover:text-white hover:bg-dark-400 opacity-0 group-hover:opacity-100 transition-opacity'; + moveBtn.title = 'Move to folder…'; + moveBtn.innerHTML = ''; + moveBtn.addEventListener('click', function (e) { e.stopPropagation(); _moveSong(song, folderName); }); + + row.appendChild(thumb); row.appendChild(meta); row.appendChild(icon); + row.appendChild(dur); row.appendChild(moveBtn); + row.addEventListener('click', function () { + if (typeof window.playSong === 'function') window.playSong(song.filename); + }); + _makeDraggable(row, song, folderName); + return row; + } + + // ── Pointer-based drag-and-drop (mousedown/mousemove/mouseup) ─────── + // HTML5 DnD blocks wheel events and gives unreliable edge positions in + // Electron — pointer events give full control over both. + var _dragState = null; + var _dragCurrentTarget = null; + var _dragRafId = null; + var _DRAG_THRESH = 5, _DRAG_ZONE = 150, _DRAG_SPEED = 50; + + function _getScrollEl() { + var el = _treeEl(); + while (el && el !== document.documentElement) { + var ov = window.getComputedStyle(el).overflowY; + if ((ov === 'auto' || ov === 'scroll' || ov === 'overlay') && el.scrollHeight > el.clientHeight) return el; + el = el.parentElement; + } + return document.scrollingElement || document.documentElement; + } + function _dragFindTarget(x, y) { + var els = document.elementsFromPoint(x, y); + for (var i = 0; i < els.length; i++) { + if ('dropFolder' in (els[i].dataset || {})) return els[i]; + } + return null; + } + function _dragHighlight(target) { + if (_dragCurrentTarget === target) return; + if (_dragCurrentTarget) _dragCurrentTarget.style.outline = ''; + _dragCurrentTarget = target; + if (target) { target.style.outline = '2px solid #3b82f6'; target.style.borderRadius = '6px'; } + } + function _dragScrollTick() { + if (!_dragState || !_dragState.live) { _dragRafId = null; return; } + var h = window.innerHeight, y = _dragState.y; + var sc = _getScrollEl(); + sc.style.scrollBehavior = 'auto'; + if (y < _DRAG_ZONE) sc.scrollTop -= _DRAG_SPEED; + else if (y > h - _DRAG_ZONE) sc.scrollTop += _DRAG_SPEED; + _dragRafId = requestAnimationFrame(_dragScrollTick); + } + function _onDragMove(e) { + if (!_dragState) return; + _dragState.x = e.clientX; _dragState.y = e.clientY; + if (!_dragState.live) { + var dx = _dragState.x - _dragState.startX, dy = _dragState.y - _dragState.startY; + if (Math.sqrt(dx * dx + dy * dy) < _DRAG_THRESH) return; + _dragState.live = true; + var ghost = document.createElement('div'); + ghost.style.cssText = 'position:fixed; pointer-events:none; z-index:9999; padding:5px 12px; background:#1e2130; border:1px solid #3b82f6; border-radius:6px; color:#e5e7eb; font-size:12px; white-space:nowrap; box-shadow:0 4px 20px rgba(0,0,0,0.5);'; + ghost.textContent = _dragState.data.label; + document.body.appendChild(ghost); + _dragState.ghost = ghost; + if (!_dragRafId) _dragRafId = requestAnimationFrame(_dragScrollTick); + } + if (_dragState.ghost) { + _dragState.ghost.style.left = (_dragState.x + 14) + 'px'; + _dragState.ghost.style.top = (_dragState.y + 14) + 'px'; + } + _dragHighlight(_dragFindTarget(_dragState.x, _dragState.y)); + } + function _onDragUp(e) { + if (!_dragState) return; + var wasDrag = _dragState.live, data = _dragState.data; + var x = e.clientX, y = e.clientY; + _endDrag(); + if (wasDrag) { + // Suppress the click that fires after mouseup so the song doesn't play. + document.addEventListener('click', function (ce) { + ce.stopPropagation(); ce.preventDefault(); + }, { capture: true, once: true }); + var target = _dragFindTarget(x, y); + if (target && data) { + var tf = target.dataset.dropFolder; + if (tf !== data.folder) _executeDrop(data, tf); + } + } + } + function _onDragKey(e) { if (e.key === 'Escape') _endDrag(); } + function _endDrag() { + if (_dragRafId) { cancelAnimationFrame(_dragRafId); _dragRafId = null; } + if (_dragState && _dragState.ghost) _dragState.ghost.remove(); + if (_dragCurrentTarget) { _dragCurrentTarget.style.outline = ''; _dragCurrentTarget = null; } + document.body.style.userSelect = ''; + _dragState = null; + document.removeEventListener('mousemove', _onDragMove); + document.removeEventListener('mouseup', _onDragUp); + document.removeEventListener('keydown', _onDragKey); + } + async function _executeDrop(data, targetFolder) { + // No optimistic tree mutation — the drag ghost gives instant visual + // feedback, and racing optimistic updates against _load() caused songs + // to snap back when dropping quickly in succession. + if (targetFolder !== '') _openFolders.add(targetFolder); + else _unsortedOpen = true; + try { + await _api('/song/move', { filename: data.filename, folder: targetFolder }); + } catch (err) { + _status('Move failed: ' + err.message, true); + } + await _load(true); + } + function _makeDraggable(el, song, folderName) { + el.style.cursor = 'grab'; + el.addEventListener('mousedown', function (e) { + if (e.button !== 0) return; + document.body.style.userSelect = 'none'; + var sel = window.getSelection(); if (sel) sel.removeAllRanges(); + _dragState = { + data: { filename: song.filename, folder: folderName || '', label: '↕ ' + (song.title || song.filename) }, + startX: e.clientX, startY: e.clientY, x: e.clientX, y: e.clientY, + live: false, ghost: null, + }; + document.addEventListener('mousemove', _onDragMove); + document.addEventListener('mouseup', _onDragUp); + document.addEventListener('keydown', _onDragKey); + }); + el.addEventListener('dragstart', function (e) { e.preventDefault(); }); + } + function _makeDropTarget(el, tf) { + el.dataset.dropFolder = (tf == null) ? '' : tf; + } + + // ── Move song dialog ──────────────────────────────────────────────── + async function _moveSong(song, currentFolderPath) { + if (!_tree) return; + var allPaths = []; + function _collect(f) { allPaths.push(f.path); (f.children || []).forEach(_collect); } + _tree.folders.forEach(_collect); + var options = ['(Unsorted)'].concat(allPaths.filter(function (p) { return p !== currentFolderPath; })); + var choice = await _prompt( + 'Move "' + (song.title || song.filename) + '" to:\n' + + options.map(function (n, i) { return i + ': ' + n; }).join('\n') + + '\n\nEnter number or folder path:', '' + ); + if (!choice && choice !== 0) return; + var dest = '', idx = parseInt(choice, 10); + if (!isNaN(idx) && idx >= 0 && idx < options.length) { + dest = idx === 0 ? '' : options[idx]; + } else { + dest = choice.trim() === '(Unsorted)' ? '' : choice.trim(); + } + try { + await _api('/song/move', { filename: song.filename, folder: dest }); + await _load(true); + } catch (err) { await _prompt('Move failed: ' + err.message, ''); } + } + + // ── Folder section ────────────────────────────────────────────────── + function _folderSection(folder, depth) { + depth = depth || 0; + var q = _query(); + var open = q ? true : _openFolders.has(folder.path); + var wrap = document.createElement('div'); + + function _countDeep(f) { + var n = f.songs.length; + (f.children || []).forEach(function (c) { n += _countDeep(c); }); + return n; + } + function _countFoldersDeep(f) { + var n = (f.children || []).length; + (f.children || []).forEach(function (c) { n += _countFoldersDeep(c); }); + return n; + } + + var hdr = document.createElement('div'); + hdr.className = 'flex items-center gap-2 px-3 py-2 rounded cursor-pointer group'; + hdr.style.transition = 'background-color 0.1s'; + + var chev = document.createElement('span'); + chev.className = 'shrink-0 w-4 h-4 text-gray-500 transition-transform duration-150'; + chev.style.transform = open ? 'rotate(90deg)' : ''; + chev.innerHTML = ''; + + var ico = document.createElement('span'); + ico.className = 'shrink-0 w-4 h-4 ' + (depth > 0 ? 'text-yellow-600' : 'text-yellow-500'); + ico.innerHTML = ''; + + var lbl = document.createElement('span'); + lbl.className = 'flex-1 truncate font-medium ' + (depth > 0 ? 'text-xs text-gray-400' : 'text-sm text-gray-200'); + lbl.textContent = folder.name; + + var cnt = document.createElement('span'); + if (cfg.deepFolderCount) { + // Lib: deep song + subfolder summary ("N songs · M subfolders"). + var _deepTotal = _countDeep(folder); + var _subCount = _countFoldersDeep(folder); + cnt.style.cssText = 'flex-shrink:0; font-size:12px; margin-right:4px; color:#6b7280;'; + var _cntText = _deepTotal + ' song' + (_deepTotal === 1 ? '' : 's'); + if (_subCount > 0) _cntText += ' · ' + _subCount + ' subfolder' + (_subCount === 1 ? '' : 's'); + cnt.textContent = _cntText; + } else { + // Nav: direct song count only. + cnt.className = 'shrink-0 text-xs text-gray-600 tabular-nums mr-1'; + cnt.textContent = String(folder.songs.length); + } + + var subBtn = document.createElement('button'); + subBtn.className = 'shrink-0 p-1 rounded text-gray-600 hover:text-white hover:bg-dark-400'; + subBtn.title = 'New subfolder'; + subBtn.innerHTML = ''; + subBtn.addEventListener('click', function (e) { e.stopPropagation(); _createFolder(folder.path); }); + + var renameBtn = document.createElement('button'); + renameBtn.className = 'shrink-0 p-1 rounded text-gray-600 hover:text-white hover:bg-dark-400'; + renameBtn.title = 'Rename folder'; + renameBtn.innerHTML = ''; + renameBtn.addEventListener('click', function (e) { e.stopPropagation(); _renameFolder(folder.path); }); + + var delBtn = document.createElement('button'); + delBtn.className = 'shrink-0 p-1 rounded text-gray-600 hover:text-red-400 hover:bg-dark-400'; + delBtn.title = 'Delete folder'; + delBtn.innerHTML = ''; + delBtn.addEventListener('click', function (e) { e.stopPropagation(); _deleteFolder(folder.path, _countDeep(folder), _countFoldersDeep(folder)); }); + + var expandChildBtn = document.createElement('button'); + var collapseChildBtn = document.createElement('button'); + if (folder.children && folder.children.length) { + expandChildBtn.className = 'shrink-0 p-1 rounded text-gray-600 hover:text-white hover:bg-dark-400'; + expandChildBtn.title = 'Expand all subfolders'; + expandChildBtn.innerHTML = ''; + expandChildBtn.addEventListener('click', function (e) { + e.stopPropagation(); + _openFolders.add(folder.path); + (folder.children || []).forEach(function (c) { _openFolders.add(c.path); }); + _storeJSON('open', [..._openFolders]); _render(); + }); + collapseChildBtn.className = 'shrink-0 p-1 rounded text-gray-600 hover:text-white hover:bg-dark-400'; + collapseChildBtn.title = 'Collapse all subfolders'; + collapseChildBtn.innerHTML = ''; + collapseChildBtn.addEventListener('click', function (e) { + e.stopPropagation(); + (folder.children || []).forEach(function (c) { _openFolders.delete(c.path); }); + _storeJSON('open', [..._openFolders]); _render(); + }); + } + + // Collapsing button group — 0 width when hidden, slides in on hover. + var btnGroup = document.createElement('div'); + btnGroup.style.cssText = 'display:flex; align-items:center; gap:2px; max-width:0; overflow:hidden; transition:max-width 0.2s ease;'; + if (folder.children && folder.children.length) { + btnGroup.appendChild(expandChildBtn); btnGroup.appendChild(collapseChildBtn); + } + btnGroup.appendChild(subBtn); btnGroup.appendChild(renameBtn); btnGroup.appendChild(delBtn); + + // mouseover (bubbles) + stopPropagation so only the innermost folder activates. + wrap.style.cssText = 'border-radius:6px; margin:1px 0;'; + wrap.addEventListener('mouseover', function (e) { + if (_dragState) return; + e.stopPropagation(); + if (_hoveredFolder && _hoveredFolder.wrap !== wrap) { + _hoveredFolder.hdr.style.backgroundColor = ''; + _hoveredFolder.wrap.style.backgroundColor = ''; + _hoveredFolder.btnGroup.style.maxWidth = '0'; + } + _hoveredFolder = { wrap: wrap, hdr: hdr, btnGroup: btnGroup }; + hdr.style.backgroundColor = 'rgba(55,65,81,0.5)'; + wrap.style.backgroundColor = 'rgba(55,65,81,0.12)'; + btnGroup.style.maxWidth = '160px'; + }); + wrap.addEventListener('mouseout', function (e) { + if (_dragState) return; + if (wrap.contains(e.relatedTarget)) return; + hdr.style.backgroundColor = ''; wrap.style.backgroundColor = ''; + btnGroup.style.maxWidth = '0'; + if (_hoveredFolder && _hoveredFolder.wrap === wrap) _hoveredFolder = null; + }); + + // cnt sits after btnGroup so it rests at the far right when buttons hidden. + hdr.appendChild(chev); hdr.appendChild(ico); hdr.appendChild(lbl); + hdr.appendChild(btnGroup); hdr.appendChild(cnt); + _makeDropTarget(hdr, folder.path); + + var content = document.createElement('div'); + if (!open) content.style.display = 'none'; + + var list = document.createElement('div'); + if (_view === 'grid') { + list.style.cssText = 'display:grid; grid-template-columns:repeat(auto-fill,150px); justify-content:start; gap:12px; padding:8px 4px 8px 24px;'; + } else { + list.className = 'ml-5 mt-0.5 space-y-0'; + } + _makeDropTarget(list, folder.path); + + var childrenWrap = document.createElement('div'); + // Suppress grid padding on empty song lists — prevents a blank amber stub. + if (_view === 'grid' && !folder.songs.length) list.style.padding = '0'; + + var _listPopulated = open; + function _populateList() { + _sortSongs(folder.songs).forEach(function (s) { + list.appendChild(_view === 'grid' ? _songCard(s, folder.path) : _songRow(s, folder.path)); + }); + (folder.children || []).forEach(function (child) { + childrenWrap.appendChild(_folderSection(child, depth + 1)); + }); + } + if (open) _populateList(); + + // depth > 0: one container with a continuous amber border-left grouping + // songs + child folders. depth == 0: children indent, root songs unbordered. + var innerWrap = null; + if (depth > 0) { + innerWrap = document.createElement('div'); + innerWrap.style.cssText = 'margin-left:32px; padding-left:10px; border-left:2px solid rgba(234,179,8,0.35);'; + innerWrap.appendChild(list); innerWrap.appendChild(childrenWrap); + content.appendChild(innerWrap); + } else { + childrenWrap.style.marginLeft = '32px'; + content.appendChild(list); content.appendChild(childrenWrap); + } + + content.addEventListener('click', function (e) { + if (_query()) return; + var bgEls = [content, list, childrenWrap]; + if (innerWrap) bgEls.push(innerWrap); + if (bgEls.indexOf(e.target) === -1) return; + if (content.style.display !== 'none') { + content.style.display = 'none'; chev.style.transform = ''; + _openFolders.delete(folder.path); _storeJSON('open', [..._openFolders]); + } + }); + + hdr.addEventListener('click', function () { + if (_query()) return; + var nowOpen = content.style.display === 'none'; + if (nowOpen && !_listPopulated) { _populateList(); _listPopulated = true; } + content.style.display = nowOpen ? '' : 'none'; + chev.style.transform = nowOpen ? 'rotate(90deg)' : ''; + if (nowOpen) _openFolders.add(folder.path); + else _openFolders.delete(folder.path); + _storeJSON('open', [..._openFolders]); + }); + + wrap.appendChild(hdr); wrap.appendChild(content); + return wrap; + } + + // ── Unsorted section ──────────────────────────────────────────────── + function _unsortedSection(songs) { + var q = _query(); + if (!songs.length && q) return null; + var wrap = document.createElement('div'); + wrap.className = 'mb-1'; + + var hdr = document.createElement('div'); + hdr.className = 'flex items-center gap-2 px-3 py-2 rounded cursor-pointer hover:bg-dark-500 transition-colors duration-100'; + + var chev = document.createElement('span'); + chev.className = 'shrink-0 w-4 h-4 text-gray-600 transition-transform duration-150'; + chev.style.transform = _unsortedOpen ? 'rotate(90deg)' : ''; + chev.innerHTML = ''; + + var ico = document.createElement('span'); + ico.className = 'shrink-0 w-4 h-4 text-gray-600'; + ico.innerHTML = ''; + + var lbl = document.createElement('span'); + lbl.className = 'flex-1 text-xs font-semibold uppercase tracking-widest text-gray-600'; + lbl.textContent = 'Unsorted'; + + var cnt = document.createElement('span'); + cnt.className = 'shrink-0 text-xs text-gray-700 tabular-nums'; + cnt.textContent = String(songs.length); + + hdr.appendChild(chev); hdr.appendChild(ico); hdr.appendChild(lbl); hdr.appendChild(cnt); + _makeDropTarget(hdr, ''); + + var list = document.createElement('div'); + if (_view === 'grid') { + list.style.cssText = 'display:grid; grid-template-columns:repeat(auto-fill,150px); justify-content:start; gap:12px; padding:8px 4px 8px 24px;'; + } else { + list.className = 'ml-5 mt-0.5 space-y-0'; + } + var _populated = _unsortedOpen; + function _populate() { + _sortSongs(songs).forEach(function (s) { + list.appendChild(_view === 'grid' ? _songCard(s, '') : _songRow(s, '')); + }); + } + if (_unsortedOpen) { _populate(); } else { list.style.display = 'none'; } + _makeDropTarget(list, ''); + + hdr.addEventListener('click', function () { + if (_query()) return; + _unsortedOpen = list.style.display === 'none'; + if (_unsortedOpen && !_populated) { _populate(); _populated = true; } + list.style.display = _unsortedOpen ? (_view === 'grid' ? 'grid' : '') : 'none'; + chev.style.transform = _unsortedOpen ? 'rotate(90deg)' : ''; + _store(cfg.unsortedKey, String(_unsortedOpen)); + }); + + wrap.appendChild(hdr); wrap.appendChild(list); + return wrap; + } + + // ── Folder management ─────────────────────────────────────────────── + async function _createFolder(parentPath) { + var msg = parentPath ? 'New subfolder name in "' + parentPath.split('/').pop() + '":' : 'New folder name:'; + var name = await _prompt(msg); + if (!name || !name.trim()) return; + try { + var body = { name: name.trim() }; + if (parentPath) body.parent = parentPath; + await _api('/folder/create', body); + var newPath = parentPath ? parentPath + '/' + name.trim() : name.trim(); + if (parentPath) _openFolders.add(parentPath); + _openFolders.add(newPath); + await _load(true); + } catch (err) { await _prompt('Create failed: ' + err.message); } + } + async function _renameFolder(folderPath) { + var oldName = folderPath.split('/').pop(); + var newName = await _prompt('Rename "' + oldName + '" to:', oldName); + if (!newName || !newName.trim() || newName.trim() === oldName) return; + try { + await _api('/folder/rename', { old: folderPath, new: newName.trim() }); + var parts = folderPath.split('/'); + parts[parts.length - 1] = newName.trim(); + var newPath = parts.join('/'); + var updated = new Set(); + _openFolders.forEach(function (p) { + if (p === folderPath) updated.add(newPath); + else if (p.startsWith(folderPath + '/')) updated.add(newPath + p.slice(folderPath.length)); + else updated.add(p); + }); + _openFolders = updated; + _storeJSON('open', [..._openFolders]); + await _load(true); + } catch (err) { await _prompt('Rename failed: ' + err.message); } + } + async function _deleteFolder(folderPath, songCount, folderCount) { + var folderName = folderPath.split('/').pop(); + var parts = []; + if (songCount > 0) parts.push(songCount + ' song' + (songCount === 1 ? '' : 's')); + if (folderCount > 0) parts.push(folderCount + ' subfolder' + (folderCount === 1 ? '' : 's')); + var msg = parts.length + ? 'Delete "' + folderName + '"? It contains ' + parts.join(' and ') + '. Songs will be moved to Unsorted.' + : 'Delete empty folder "' + folderName + '"?'; + var ok = await _confirm(msg); + if (!ok) return; + try { + await _api('/folder/delete', { name: folderPath }); + var toDelete = []; + _openFolders.forEach(function (p) { + if (p === folderPath || p.startsWith(folderPath + '/')) toDelete.push(p); + }); + toDelete.forEach(function (p) { _openFolders.delete(p); }); + _storeJSON('open', [..._openFolders]); + await _load(true); + } catch (err) { await _prompt('Delete failed: ' + err.message); } + } + + // ── Expand / collapse all ─────────────────────────────────────────── + function _expandAll() { + if (!_tree) return; + function _addPaths(f) { _openFolders.add(f.path); (f.children || []).forEach(_addPaths); } + _tree.folders.forEach(_addPaths); + _unsortedOpen = true; + _storeJSON('open', [..._openFolders]); _store(cfg.unsortedKey, 'true'); + _render(); + } + function _collapseAll() { + _openFolders.clear(); _unsortedOpen = false; + _storeJSON('open', []); _store(cfg.unsortedKey, 'false'); + _render(); + } + + // ── Render ────────────────────────────────────────────────────────── + function _render() { + _hoveredFolder = null; // DOM is rebuilt; discard any stale reference + var treeEl = _treeEl(); + if (!treeEl) return; + var data = _filtered(); + var frag = document.createDocumentFragment(); + var unsorted = _unsortedSection(data.root_songs); + if (unsorted) frag.appendChild(unsorted); + data.folders.forEach(function (f) { frag.appendChild(_folderSection(f)); }); + if (!data.folders.length && !data.root_songs.length) { + var emp = document.createElement('div'); + emp.className = 'flex flex-col items-center justify-center py-24 gap-3 text-gray-700'; + emp.innerHTML = '' + + '

' + (_query() ? 'No songs match your search.' : 'No songs found.') + '

'; + frag.appendChild(emp); + } + treeEl.innerHTML = ''; treeEl.appendChild(frag); + + // Lib: update the library count line ("N songs · M folders"). + if (cfg.countId) { + var countEl = _el(cfg.countId); + if (countEl) { + var total = data.root_songs.length; + var folderCount = 0; + function _countDeep(f) { + total += f.songs.length; + folderCount += 1; + (f.children || []).forEach(_countDeep); + } + data.folders.forEach(_countDeep); + var songStr = total + ' song' + (total === 1 ? '' : 's'); + var folderStr = folderCount + ' folder' + (folderCount === 1 ? '' : 's'); + countEl.textContent = songStr + ' · ' + folderStr; + } + } + } + + // ── Toolbar injection (lib surface, once) ─────────────────────────── + function _injectToolbar() { + if (_toolbarDone) return; + var ctrl = _el(cfg.controlsId); + if (!ctrl) { + ctrl = document.createElement('div'); + ctrl.id = cfg.controlsId; + var treeEl = _treeEl(); + if (!treeEl) return; + treeEl.parentNode.insertBefore(ctrl, treeEl); + } + ctrl.style.cssText = 'display:flex; align-items:center; gap:8px; margin-bottom:12px;'; + ctrl.innerHTML = ''; + + var viewGroup = document.createElement('div'); + viewGroup.style.cssText = 'display:flex; background:#1f2937; border:1px solid #374151; border-radius:10px; overflow:hidden;'; + var listBtn = document.createElement('button'); + listBtn.title = 'List view'; + listBtn.style.cssText = 'padding:7px 10px; border:none; cursor:pointer; transition:background 0.1s, color 0.1s;'; + listBtn.innerHTML = ''; + var gridBtn = document.createElement('button'); + gridBtn.title = 'Grid view'; + gridBtn.style.cssText = 'padding:7px 10px; border:none; cursor:pointer; transition:background 0.1s, color 0.1s;'; + gridBtn.innerHTML = ''; + function _applyViewBtns() { + listBtn.style.background = _view === 'list' ? '#374151' : 'transparent'; + listBtn.style.color = _view === 'list' ? '#e5e7eb' : '#6b7280'; + gridBtn.style.background = _view === 'grid' ? '#374151' : 'transparent'; + gridBtn.style.color = _view === 'grid' ? '#e5e7eb' : '#6b7280'; + } + _applyViewBtns(); + listBtn.addEventListener('click', function () { + if (_view === 'list') return; + _view = 'list'; _store('view', 'list'); _applyViewBtns(); _render(); + }); + gridBtn.addEventListener('click', function () { + if (_view === 'grid') return; + _view = 'grid'; _store('view', 'grid'); _applyViewBtns(); _render(); + }); + viewGroup.appendChild(listBtn); viewGroup.appendChild(gridBtn); + + var newBtn = _makeToolbarBtn( + '', + null, 'New parent folder' + ); + newBtn.addEventListener('click', function () { _createFolder(); }); + var expBtn = _makeToolbarBtn( + '', + null, 'Expand all' + ); + expBtn.addEventListener('click', _expandAll); + var colBtn = _makeToolbarBtn( + '', + null, 'Collapse all' + ); + colBtn.addEventListener('click', _collapseAll); + + ctrl.appendChild(viewGroup); + ctrl.appendChild(newBtn); + ctrl.appendChild(expBtn); + ctrl.appendChild(colBtn); + + _toolbarDone = true; + } + function _makeToolbarBtn(iconHtml, label, title) { + var btn = document.createElement('button'); + btn.title = title || ''; + btn.style.cssText = 'display:flex; align-items:center; gap:6px; padding:7px 12px; background:#1f2937; border:1px solid #374151; border-radius:10px; color:#9ca3af; cursor:pointer; font-size:13px; white-space:nowrap; transition:color 0.1s, border-color 0.1s;'; + btn.innerHTML = iconHtml + (label ? '' + label + '' : ''); + btn.addEventListener('mouseenter', function () { btn.style.color = '#e5e7eb'; btn.style.borderColor = '#6b7280'; }); + btn.addEventListener('mouseleave', function () { btn.style.color = '#9ca3af'; btn.style.borderColor = '#374151'; }); + return btn; + } + + // ── Unload (lib surface) ──────────────────────────────────────────── + function _unload() { + if (!cfg.searchInputId) return; + var el = _el(cfg.searchInputId); + if (el) el.style.maxWidth = ''; + } + + // ── Init (nav surface) ────────────────────────────────────────────── + function _init() { + _closeDropdown(); + _fixHeight(); + window.addEventListener('resize', _fixHeight); + + var search = _el('fb-search'); + var reload = _el('fb-reload'); + var expandAll = _el('fb-expand-all'); + var collapseAll = _el('fb-collapse-all'); + var newFolder = _el('fb-new-folder'); + var filterBtn = _el('fb-filter'); + var filterBack = _el('fb-filter-backdrop'); + var viewList = _el('fb-view-list'); + var viewGrid = _el('fb-view-grid'); + + if (!search) return; + + search.style.position = 'relative'; + search.style.zIndex = '100'; + + function _updateViewButtons() { + if (!viewList || !viewGrid) return; + viewList.style.color = _view === 'list' ? '#ffffff' : ''; + viewList.style.background = _view === 'list' ? '#1f2937' : ''; + viewGrid.style.color = _view === 'grid' ? '#ffffff' : ''; + viewGrid.style.background = _view === 'grid' ? '#1f2937' : ''; + } + _updateViewButtons(); + if (viewList) viewList.addEventListener('click', function () { + if (_view === 'list') return; + _view = 'list'; _store('view', 'list'); _updateViewButtons(); _render(); + }); + if (viewGrid) viewGrid.addEventListener('click', function () { + if (_view === 'grid') return; + _view = 'grid'; _store('view', 'grid'); _updateViewButtons(); _render(); + }); + + var sortSel = _el('fb-sort'); + var sortDirBtn = _el('fb-sort-dir'); + var sortDirIco = _el('fb-sort-dir-icon'); + function _updateSortDir() { + if (!sortDirBtn) return; + var isAsc = _sortDir === 'asc'; + var active = _sort !== 'default'; + sortDirBtn.title = isAsc ? 'Ascending' : 'Descending'; + sortDirBtn.style.opacity = active ? '' : '0.35'; + sortDirBtn.style.cursor = active ? '' : 'default'; + if (sortDirIco) { + sortDirIco.innerHTML = isAsc + ? '' + : ''; + } + } + _updateSortDir(); + if (sortSel) { + sortSel.value = _sort; + sortSel.addEventListener('change', function () { + _sort = sortSel.value; _store('sort', _sort); _updateSortDir(); _render(); + }); + } + if (sortDirBtn) { + sortDirBtn.addEventListener('click', function () { + if (_sort === 'default') return; + _sortDir = _sortDir === 'asc' ? 'desc' : 'asc'; + _store('sortDir', _sortDir); _updateSortDir(); _render(); + }); + } + + search.addEventListener('input', function () { _render(); }); + search.addEventListener('click', function (e) { e.stopPropagation(); _closeDropdown(); }); + + reload.addEventListener('click', function () { _loaded = false; _load(true); }); + expandAll.addEventListener('click', _expandAll); + collapseAll.addEventListener('click', _collapseAll); + newFolder.addEventListener('click', function () { _createFolder(); }); + if (filterBtn) filterBtn.addEventListener('click', _openFilterPanel); + if (filterBack) filterBack.addEventListener('click', _closeFilterPanel); + _updateFilterBadge(); + + if (!_loaded) _load(true); + } + + // ── Screen changed (nav surface) ──────────────────────────────────── + function _onScreenChanged(ev) { + var id = ev && ev.detail && ev.detail.id; + if (id === cfg.screenId) { + _closeDropdown(); + if (!_loaded) _load(true); + } + } + + return { + load: _load, + unload: _unload, + init: _init, + onScreenChanged: _onScreenChanged, + render: _render, + }; +} + +// ════════════════════════════════════════════════════════════════════════ +// Surface configs +// ════════════════════════════════════════════════════════════════════════ +var NAV_CONFIG = { + apiBase: API, + storePrefix: 'fo:', + treeId: 'fb-tree', + screenId: 'plugin-folder_library', + unsortedKey: 'unsorted_open', + ownsStatus: true, + ownsFilterPanel:true, + ownsSort: true, + songBadges: true, + deepFolderCount:false, + autoExpandTop: false, + injectToolbar: false, + getSearchEl: function () { return document.getElementById('fb-search'); }, +}; + +var LIB_CONFIG = { + apiBase: API, + storePrefix: 'fo:lib:', + treeId: 'lib-folder-tree', + controlsId: 'lib-folder-controls', + countId: 'lib-count', + searchInputId: 'lib-filter', + unsortedKey: 'unsorted', + ownsStatus: false, + ownsFilterPanel:false, + ownsSort: false, + songBadges: false, + deepFolderCount:true, + autoExpandTop: true, + injectToolbar: true, + getSearchEl: function () { return document.getElementById('v3-search') || document.getElementById('lib-filter'); }, + getFilterParams: function () { + return (typeof window.v3Songs?.filterParams === 'function') + ? window.v3Songs.filterParams() + : (typeof window.feedBackLibFilterParams === 'function') + ? window.feedBackLibFilterParams() + : (typeof window.slopsmithLibFilterParams === 'function') + ? window.slopsmithLibFilterParams() : ''; + }, + getHostArtist: function () { return (typeof window.v3Songs?.getArtist === 'function') ? window.v3Songs.getArtist() : ''; }, + getHostAlbum: function () { return (typeof window.v3Songs?.getAlbum === 'function') ? window.v3Songs.getAlbum() : ''; }, + getHostSort: function () { + return (typeof window.v3Songs?.getSort === 'function') + ? window.v3Songs.getSort() + : (document.getElementById('lib-sort') || document.getElementById('v3-songs-sort') || {}).value || ''; + }, +}; + +// ════════════════════════════════════════════════════════════════════════ +// Adapter A — v2 nav screen (renders into #fb-tree) +// ════════════════════════════════════════════════════════════════════════ +if (!window.__folderLibraryNavLoaded) { + window.__folderLibraryNavLoaded = true; + var _nav = createFolderSurface(NAV_CONFIG); + + if (window.feedBack && typeof window.feedBack.on === 'function') { + window.feedBack.on('screen:changed', _nav.onScreenChanged); + } else if (window.slopsmith && typeof window.slopsmith.on === 'function') { + window.slopsmith.on('screen:changed', _nav.onScreenChanged); + } else { + var _deadline = performance.now() + 5000; + var _pollId = setInterval(function () { + var bus = window.feedBack || window.slopsmith; + if (bus && typeof bus.on === 'function') { + clearInterval(_pollId); + bus.on('screen:changed', _nav.onScreenChanged); + } else if (performance.now() > _deadline) { + clearInterval(_pollId); + } + }, 100); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', _nav.init, { once: true }); + } else { + _nav.init(); + } +} + +// ════════════════════════════════════════════════════════════════════════ +// Adapter B — v3 library view (window.folderLibrary, renders into #lib-folder-tree) +// ════════════════════════════════════════════════════════════════════════ +// Idempotency: the factory instance is a persistent singleton (so its in-memory +// state survives a script re-injection), but window.folderLibrary is ALWAYS +// (re)assigned on every evaluation — the host's reload path does +// `delete window.folderLibrary` and re-injects this script expecting it back. +if (!window.__folderLibraryLib) { + window.__folderLibraryLib = createFolderSurface(LIB_CONFIG); +} +(function () { + var _lib = window.__folderLibraryLib; + + window.folderLibrary = { + load: function (force) { return _lib.load(force); }, + unload: function () { _lib.unload(); }, + }; + + // Auto-load if folder view was already active when this script was injected. + // On a hard refresh, setLibView() runs before plugins load, so + // window.folderLibrary didn't exist yet and the host's load call silently + // skipped. Now that we're defined, kick off the load if #lib-folder-tree is + // currently visible. + var treeEl = document.getElementById('lib-folder-tree'); + if (treeEl && !treeEl.classList.contains('hidden')) { + _lib.load(); + } +}()); + +})(); diff --git a/static/app.js b/static/app.js index 80714c9..6c0a696 100644 --- a/static/app.js +++ b/static/app.js @@ -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(); diff --git a/static/index.html b/static/index.html index 6341907..e49cc8a 100644 --- a/static/index.html +++ b/static/index.html @@ -103,10 +103,13 @@ +
' + provOpts + '' : '') + '' + '' + - '
' + + '
' + '' + '' + '' + @@ -913,6 +939,8 @@ '' + '
' + '' + + '' + + '' + '
' + // Filter drawer + overlay '' + @@ -979,14 +1007,17 @@ e.stopImmediatePropagation(); toggleSelect(card.getAttribute('data-fn'), card); }, true); - const setView = (v) => { + const setView = async (v) => { state.view = v; byId('v3-songs-grid-btn').className = 'px-3 py-2 text-sm ' + (v === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); byId('v3-songs-tree-btn').className = 'px-3 py-2 text-sm ' + (v === 'tree' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); + byId('v3-songs-folder-btn').className = 'px-3 py-2 text-sm ' + (v === 'folder' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); + if (v === 'folder') await _ensureFolderLibrary(); return reload(); }; byId('v3-songs-grid-btn').addEventListener('click', () => setView('grid')); byId('v3-songs-tree-btn').addEventListener('click', () => setView('tree')); + byId('v3-songs-folder-btn').addEventListener('click', () => setView('folder')); // Await the initial load so a caller awaiting render() (the scroll // restore on screen re-entry) sees a populated grid + real state.total // before it tries to page deeper. @@ -1042,6 +1073,8 @@ if (state.renderedHash !== _libraryStateHash()) { reload(); return; } document.getElementById('v3-songs-grid')?.classList.toggle('hidden', state.view !== 'grid'); document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree'); + document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder'); + { const _fc = document.getElementById('lib-folder-controls'); if (_fc) _fc.style.display = state.view === 'folder' ? 'flex' : 'none'; } return; } @@ -1093,6 +1126,20 @@ reload: reload, search: search, setQuery: (q) => { state.q = q || ''; }, + getSort: () => state.sort, + getArtist: () => state.artist, + getAlbum: () => state.album, + filterParams: () => { + const f = state.filters; + const p = new URLSearchParams(); + if (f.arr_has.length) p.set('arrangements_has', f.arr_has.join(',')); + if (f.arr_lacks.length) p.set('arrangements_lacks', f.arr_lacks.join(',')); + if (f.stem_has.length) p.set('stems_has', f.stem_has.join(',')); + if (f.stem_lacks.length) p.set('stems_lacks', f.stem_lacks.join(',')); + if (f.lyrics) p.set('has_lyrics', f.lyrics); + if (f.tunings.length) p.set('tunings', f.tunings.join(',')); + return p.toString(); + }, _scrollHelpers: { SCROLL_STATE_KEY, buildLibraryStateHash, diff --git a/tests/plugins/folder_library/test_routes.py b/tests/plugins/folder_library/test_routes.py new file mode 100644 index 0000000..6b1cbcf --- /dev/null +++ b/tests/plugins/folder_library/test_routes.py @@ -0,0 +1,208 @@ +"""Tests for the folder_library plugin backend. + +Covers the pure path-safety helpers and end-to-end behaviour of the two +filesystem-mutating endpoints whose bugs this guards against: + * /song/move must reject path traversal in `filename` (no escaping DLC_DIR). + * /folder/delete must relocate EVERY song to the root, never destroy a song + whose name collides with an existing root song. + +The plugin's routes.py is loaded under a unique module name via importlib so it +does not collide in sys.modules with other bundled plugins' routes.py. +""" + +import importlib.util +import logging +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +_ROUTES_PATH = ( + Path(__file__).resolve().parents[3] + / "plugins" / "folder_library" / "routes.py" +) +_spec = importlib.util.spec_from_file_location("folder_library_routes", _ROUTES_PATH) +fl = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(fl) + + +# ── Pure helpers ──────────────────────────────────────────────────────────── + +class TestSafeName: + @pytest.mark.parametrize("name", ["Rock", "Folder 1", "A-B_C", "über", "AC.DC"]) + def test_accepts_ordinary_names(self, name): + assert fl._safe_name(name) is True + + @pytest.mark.parametrize("name", [ + "", "..", ".", "../x", "a/b", "a\\b", "a:b", "a*b", "a?b", + 'a"b', "ab", "a|b", " lead", "lead ", + ]) + def test_rejects_unsafe_names(self, name): + assert fl._safe_name(name) is False + + +class TestSafePath: + @pytest.mark.parametrize("path", ["A", "A/B", "A/B/C", "Rock/Sub Folder"]) + def test_accepts_safe_paths(self, path): + assert fl._safe_path(path) is True + + @pytest.mark.parametrize("path", [ + "", "..", "../x", "A/../B", "A/..", "/A", "A//B", "A/b\\c", + ]) + def test_rejects_traversal_and_empty(self, path): + assert fl._safe_path(path) is False + + +class TestIsWithin: + def test_inside(self, tmp_path): + assert fl._is_within(tmp_path, tmp_path / "a" / "b") is True + + def test_traversal_escapes(self, tmp_path): + root = tmp_path / "dlc" + root.mkdir() + assert fl._is_within(root, root / ".." / "secret") is False + + def test_sibling_prefix_not_within(self, tmp_path): + root = tmp_path / "dlc" + root.mkdir() + (tmp_path / "dlc-evil").mkdir() + assert fl._is_within(root, tmp_path / "dlc-evil" / "x") is False + + +class TestIsSong: + @pytest.mark.parametrize("name", ["a.sloppak", "a.feedpak", "A.SLOPPAK"]) + def test_song_extensions(self, name, tmp_path): + assert fl._is_song(tmp_path / name) is True + + @pytest.mark.parametrize("name", ["a.txt", "a", "a.zip"]) + def test_non_song(self, name, tmp_path): + assert fl._is_song(tmp_path / name) is False + + +# ── Endpoint behaviour ────────────────────────────────────────────────────── + +@pytest.fixture +def env(tmp_path): + dlc = tmp_path / "dlc" + dlc.mkdir() + app = FastAPI() + fl.setup(app, { + "log": logging.getLogger("folder_library_test"), + "get_dlc_dir": lambda: str(dlc), + "extract_meta": lambda p: {}, + }) + return TestClient(app), dlc, tmp_path + + +def _song(path: Path, content: str): + path.write_text(content) + + +def _loose_song(folder: Path): + """Minimal valid loose-folder song: audio + an arrangement XML ( root).""" + folder.mkdir(parents=True, exist_ok=True) + (folder / "audio.wem").write_bytes(b"\x00") + (folder / "lead.xml").write_text("Loose") + + +class TestLooseFolderRecognition: + def test_is_song_detects_loose_folder_dir(self, tmp_path): + loose = tmp_path / "MyLoose" + _loose_song(loose) + assert fl._is_song(loose) is True + + def test_plain_folder_is_not_a_song(self, tmp_path): + plain = tmp_path / "Plain" + plain.mkdir() + (plain / "notes.txt").write_text("x") + assert fl._is_song(plain) is False + + def test_loose_folder_surfaces_as_song_not_child_folder(self, env): + client, dlc, _ = env + _loose_song(dlc / "Rock" / "LooseSong") + r = client.get("/api/plugins/folder_library/tree") + assert r.status_code == 200, r.text + rock = next(f for f in r.json()["folders"] if f["name"] == "Rock") + assert "LooseSong" in {s["title"] for s in rock["songs"]} + assert "LooseSong" not in {c["name"] for c in rock["children"]} + + +class TestMoveTraversal: + def test_rejects_parent_traversal_and_does_not_move(self, env): + client, dlc, tmp = env + secret = tmp / "secret.sloppak" + _song(secret, "TOP SECRET") + r = client.post("/api/plugins/folder_library/song/move", + json={"filename": "../secret.sloppak", "folder": ""}) + assert r.status_code == 400 + # The external file must NOT have been moved into the served library. + assert secret.exists() + assert not (dlc / "secret.sloppak").exists() + + def test_rejects_absolute_style_traversal(self, env): + client, dlc, tmp = env + r = client.post("/api/plugins/folder_library/song/move", + json={"filename": "../../etc/passwd", "folder": ""}) + assert r.status_code == 400 + + def test_valid_move_succeeds(self, env): + client, dlc, _ = env + _song(dlc / "A.sloppak", "a") + (dlc / "Dest").mkdir() + r = client.post("/api/plugins/folder_library/song/move", + json={"filename": "A.sloppak", "folder": "Dest"}) + assert r.status_code == 200 + assert not (dlc / "A.sloppak").exists() + assert (dlc / "Dest" / "A.sloppak").read_text() == "a" + + +class TestDeleteFolderNoDataLoss: + def test_colliding_song_is_relocated_not_destroyed(self, env): + client, dlc, _ = env + # A root song and a same-named song inside the folder being deleted. + _song(dlc / "song.sloppak", "ROOT") + (dlc / "F").mkdir() + _song(dlc / "F" / "song.sloppak", "INSIDE") + + r = client.post("/api/plugins/folder_library/folder/delete", + json={"name": "F"}) + assert r.status_code == 200, r.text + + # Folder gone, original root song intact, and the colliding song + # survived under a de-duplicated name (NOT destroyed by rmtree). + assert not (dlc / "F").exists() + assert (dlc / "song.sloppak").read_text() == "ROOT" + survivors = {p.read_text() for p in dlc.glob("*.sloppak")} + assert "INSIDE" in survivors + assert len(list(dlc.glob("*.sloppak"))) == 2 + + def test_nested_songs_all_relocated(self, env): + client, dlc, _ = env + (dlc / "F" / "Sub").mkdir(parents=True) + _song(dlc / "F" / "a.sloppak", "a") + _song(dlc / "F" / "Sub" / "b.sloppak", "b") + r = client.post("/api/plugins/folder_library/folder/delete", + json={"name": "F"}) + assert r.status_code == 200, r.text + assert not (dlc / "F").exists() + names = {p.name for p in dlc.glob("*.sloppak")} + assert names == {"a.sloppak", "b.sloppak"} + + +class TestFolderOpsValidation: + def test_create_rejects_unsafe_name(self, env): + client, _, _ = env + r = client.post("/api/plugins/folder_library/folder/create", + json={"name": "../evil"}) + assert r.status_code == 400 + + def test_create_and_rename_roundtrip(self, env): + client, dlc, _ = env + assert client.post("/api/plugins/folder_library/folder/create", + json={"name": "New"}).status_code == 200 + assert (dlc / "New").is_dir() + assert client.post("/api/plugins/folder_library/folder/rename", + json={"old": "New", "new": "Renamed"}).status_code == 200 + assert (dlc / "Renamed").is_dir() + assert not (dlc / "New").exists()