# 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).