diff --git a/README.md b/README.md index f187330..e717b6d 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,10 @@ A Docker-based web application for managing your tabletop RPG PDF collection. Br - **Global Audio Player** - A persistent pop-out player that keeps playing while you navigate. Build a local queue by playing a whole folder, queueing tracks one at a time ("Play Next"), having a GM play a campaign resource group, or playing all the audio embedded in a wiki note. Expand it to see and reorder upcoming tracks, with a repeat-current-track toggle - **Bookmarks** - Per-user page and text-selection bookmarks with inline highlights - **Favorites** - Save systems, books, maps, tokens, and audio for quick access +- **Shared Tags** - One tag catalog across systems, books, maps, tokens, and audio. Tags match on a lowercased internal key with an editable display name, so "Draw Steel" and "draw steel" are the same tag. A dedicated Tags page lists every tag with usage counts, lets you rename/merge/delete, and browses all items carrying a tag; clicking a tag anywhere jumps there. Filter dropdowns show only tags used on the current page, and campaign resources can be bulk-added by tag - **View Modes** - Toggle the systems, books, maps, tokens, and audio grids between card, compact, and list layouts; each content type remembers its own default (configurable in Account Settings) while the in-page toggle is a per-tab override. Cards and list rows include quick download and favorite buttons. -- **Metadata Editor** - Add descriptions, tags, genre, publisher links, and character builder URLs +- **Metadata Editor** - Rich metadata for systems (multiple genres, dice/materials, system family, parent system + edition, license, year, and multiple generic + character-builder links) and books (authors, artists, genres, ISBN, version, language, a per-book license override, a variable-precision publication date, and multiple links). Genres, system families, parent systems, licenses, and dice/materials are drawn from curated lists you manage in **Settings → Metadata** (each section collapsible; defaults plus your own custom values). A *parent system* groups related systems (e.g. D&D 5e and AD&D under "Dungeons & Dragons"), and an *edition* string combines with it for display ("Cyberpunk" + "Red" → "Cyberpunk Red") +- **Sort & Filter** - Sort systems by name, book count, total page count, or year, and books by title, page count, or year. A shared filter modal covers genre, system family, parent system, edition, dice/materials, tags, favourites, and explicit content. Named filter presets are saved to your account (server-side, so they follow you across devices), and one preset per view can be set as the default you land on - **Bulk Actions** - Multi-select books, maps, tokens, and audio (click, shift-click for a range, ⌘/Ctrl-click to toggle) then bulk tag, add to a campaign, or edit metadata via a carousel - **Campaigns** - Track GM-run and personal campaigns; a markdown notes wiki with deep linking, Markdown/JSON/LegendKeeper import & export, character art and sheets, linked resources, and scheduling - **OPDS Catalog** - Each user can generate a personal OPDS feed URL to connect e-reader apps directly to their library @@ -235,19 +237,22 @@ Archive files placed anywhere under `books/` are shown alongside your books in t Archives are treated as opaque downloads - Grimoire does not extract or read their contents, so clicking one downloads the file rather than opening the reader. They're also included when you download a whole system, category, or subfolder as an archive. Comic-book archives (`.cbz`, `.cbr`, `.cb7`, `.cbt`) additionally get a cover thumbnail generated from the first image inside them. -#### System-agnostic collections +#### Special collections (system-agnostic & one-page) -Some books don't belong to a single game system - reference material, zines, art books, or rulesets like Ironsworn or Mothership that span multiple systems. Create a folder whose name is one of the recognized system-agnostic names and Grimoire will display its contents in a separate **System-Agnostic** section on the library page, outside the normal game-system grid. +Some books don't belong to a single game system - reference material, zines, art books, or rulesets like Ironsworn or Mothership that span multiple systems. And some "systems" are really a bucket of many tiny games: one-page and small RPGs. Create a folder whose name is one of the recognized names below and Grimoire will display its contents in a separate **Special Collections** section on the library page, outside the normal game-system grid. **Recognized folder names** (case-insensitive): -| Folder name | Example | -|---|---| -| `System Agnostic` | `books/System Agnostic/` | -| `Generic` | `books/Generic/` | -| `Any` | `books/Any/` | +| Folder name | Collection | Example | +|---|---|---| +| `System Agnostic` | System-agnostic | `books/System Agnostic/` | +| `Generic` | System-agnostic | `books/Generic/` | +| `Any` | System-agnostic | `books/Any/` | +| `One-Page RPGs` | One-page / small RPGs | `books/One-Page RPGs/` | +| `Single-Page RPGs` | One-page / small RPGs | `books/Single-Page RPGs/` | +| `One-Shot RPGs` | One-page / small RPGs | `books/One-Shot RPGs/` | -Subfolders directly under the agnostic root become **custom category headings** - whatever you name them is what appears in the UI. There is no keyword matching; the folder name is used as-is (slugified). +Subfolders directly under one of these roots become **custom category headings** - whatever you name them is what appears in the UI. There is no keyword matching; the folder name is used as-is (slugified). ``` books/ @@ -276,6 +281,23 @@ books/ Users with explicit content disabled will not see this system or its books. +#### Sort-order prefixes + +To pull a system to the top of an alphabetically-sorted file browser, you can +prefix its folder name with `!`, `$`, or `%`. Grimoire strips a leading run of +those characters when deriving the system name (only the leading run — internal +occurrences are kept): + +``` +books/ +├── !!Dungeons & Dragons/ → "Dungeons & Dragons" +├── !system-agnostic/ → still the System-Agnostic collection +└── $%Pathfinder 2e/ → "Pathfinder 2e" +``` + +The prefix stacks with `(nsfw)`, so `!!Forbidden Lore (NSFW)` becomes the +explicit system "Forbidden Lore". + ### Book metadata from OPF files Grimoire reads [OPF](https://idpf.org/epub/20/spec/OPF_2.0.1_draft.htm) sidecar files to populate book metadata automatically on first scan. OPF files are the format used by [Calibre](https://calibre-ebook.com/) and many other library managers. @@ -367,18 +389,25 @@ Drop a `tags.json` file into any `maps/`, `tokens/`, or `audio/` folder (or subf | `"subfolder"` | A subfolder | | `"subfolder/file.png"` | A file inside a subfolder | -Values are arrays of tag strings. +Values are arrays of tag strings. The casing you write is used as the tag's +display name the first time it's seen. ```json { - ".": ["dungeon", "fantasy"], - "cave-entrance.png": ["cave", "outdoors"], - "boss-arena": ["combat", "finale"], - "boss-arena/throne-room.png": ["throne", "indoor"] + ".": ["Dungeon", "Fantasy"], + "cave-entrance.png": ["Cave", "Outdoors"], + "boss-arena": ["Combat", "Finale"], + "boss-arena/throne-room.png": ["Throne", "Indoor"] } ``` -Tags are applied (or updated) every time the library is rescanned. Tags set via the web UI are replaced by the values in `tags.json` on the next scan. +`tags.json` is **additive and read-only**: on every rescan it only *adds* the +tags it lists — it never removes tags you set (or removed) in the web UI, and it +never overwrites a tag's display name once the tag exists. A new tag is created +using the casing in the file; renaming a tag later in the web UI sticks, because +the display name lives in the app's tag catalog rather than in `tags.json` (which +the app treats as read-only and never rewrites). Tags are matched +case-insensitively, so `"dungeon"` and `"Dungeon"` are the same tag. --- diff --git a/backend/config.py b/backend/config.py index d3127a5..bf0639f 100644 --- a/backend/config.py +++ b/backend/config.py @@ -24,6 +24,7 @@ THUMB_DIR = os.path.join(DATA_PATH, "thumbnails") PAGE_CACHE_DIR = os.path.join(DATA_PATH, "page_cache") CAMPAIGN_UPLOAD_DIR = os.path.join(DATA_PATH, "campaign_uploads") +SYSTEM_COVER_DIR = os.path.join(DATA_PATH, "system_covers") VALKEY_URL = os.environ.get("VALKEY_URL", "") # OCR: image-only PDFs (scanned pages with no embedded text layer) can be run @@ -44,6 +45,13 @@ OCR_ENABLED = os.environ.get("OCR_ENABLED", "true").lower() == "true" OCR_LANGUAGES = os.environ.get("OCR_LANGUAGES", "eng").strip() or "eng" +# Set true to disable the "update available" check that proxies GitHub's +# releases API. When disabled, /api/latest-release always returns null and no +# outbound request to GitHub is ever made. +DISABLE_VERSION_CHECKING = ( + os.environ.get("DISABLE_VERSION_CHECKING", "false").lower() == "true" +) + def _read_ocr_concurrency() -> int: """Parallel-OCR worker count. 0 = OCR disabled; negatives clamp to 0; bad @@ -307,6 +315,7 @@ def clear(self) -> None: os.makedirs(os.path.join(CAMPAIGN_UPLOAD_DIR, "art"), exist_ok=True) os.makedirs(os.path.join(CAMPAIGN_UPLOAD_DIR, "sheets"), exist_ok=True) os.makedirs(os.path.join(CAMPAIGN_UPLOAD_DIR, "files"), exist_ok=True) +os.makedirs(SYSTEM_COVER_DIR, exist_ok=True) engine, SessionLocal = init_db(DB_PATH) diff --git a/backend/indexer/__init__.py b/backend/indexer/__init__.py index 4edee9b..9bdb5db 100644 --- a/backend/indexer/__init__.py +++ b/backend/indexer/__init__.py @@ -46,8 +46,11 @@ agnostic_category, folder_category_inference_disabled, guess_category, + is_one_page_folder, + is_special_collection_folder, is_system_agnostic_folder, slugify, + strip_sort_prefix, ) # --- Archive + thumbnail helpers ----------------------------------------------- diff --git a/backend/indexer/categories.py b/backend/indexer/categories.py index 12ec74b..5938701 100644 --- a/backend/indexer/categories.py +++ b/backend/indexer/categories.py @@ -10,12 +10,30 @@ CATEGORY_MAP, NO_AUTO_CATEGORY_MARKER, # noqa: F401 (re-exported for callers) UNCATEGORIZED, + _ONE_PAGE_SLUGS, _SYSTEM_AGNOSTIC_SLUGS, ) logger = logging.getLogger("grimoire.indexer") +# Leading characters people prepend to system folders purely to steer the +# alphabetical sort order of their file browser (e.g. "!!Dungeons & Dragons"). +# Only these three are recognized, and only as a contiguous leading run — once a +# non-special character is read, the rest is the real name. +_SORT_PREFIX_CHARS = "!$%" + + +def strip_sort_prefix(name: str) -> str: + """Strip leading sort-order prefix characters (``!$%``) from a folder name. + + Only the contiguous run of these characters at the very start is removed; + everything from the first non-prefix character onward is kept verbatim + (including internal ``!``/``$``/``%``). Surrounding whitespace is trimmed. + """ + return name.lstrip(_SORT_PREFIX_CHARS).strip() + + def slugify(name: str) -> str: """Create a URL-safe slug from a name.""" slug = name.lower().strip() @@ -30,6 +48,20 @@ def is_system_agnostic_folder(folder_name: str) -> bool: return slugify(folder_name) in _SYSTEM_AGNOSTIC_SLUGS +def is_one_page_folder(folder_name: str) -> bool: + """Return True if this top-level books folder is the one-page / small-RPG collection.""" + return slugify(folder_name) in _ONE_PAGE_SLUGS + + +def is_special_collection_folder(folder_name: str) -> bool: + """Return True for any special collection folder (agnostic or one-page). + + Both use their immediate subfolder name as the category label rather than + the normal CATEGORY_MAP inference. + """ + return is_system_agnostic_folder(folder_name) or is_one_page_folder(folder_name) + + def _normalize_folder(name: str) -> str: """Collapse hyphens, underscores, and whitespace to a single space for category matching.""" return re.sub(r"[-_\s]+", " ", name.lower()).strip() diff --git a/backend/indexer/constants.py b/backend/indexer/constants.py index e90a6e4..af1db7b 100644 --- a/backend/indexer/constants.py +++ b/backend/indexer/constants.py @@ -67,6 +67,17 @@ } ) +# Normalized folder names treated as the "one-page / small RPG" collection — a +# special sibling of the system-agnostic collection (issue #202). Books here use +# their immediate subfolder name as the category label, exactly like agnostic. +_ONE_PAGE_SLUGS = frozenset( + { + "one-page-rpgs", + "single-page-rpgs", + "one-shot-rpgs", + } +) + IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".svg"} PDF_EXTS = {".pdf"} DOC_EXTS = {".pdf", ".epub", ".djvu"} @@ -120,4 +131,6 @@ METADATA_MODES = ("new", "missing", "replace") # Book fields that can be sourced from an OPF sidecar. -_OPF_BOOK_FIELDS = ("title", "authors", "description", "publisher", "year", "tags") +# Note: OPF ``tags`` are applied separately via the shared-tag service (issue +# #235); they are intentionally NOT in this setattr list (no column to set). +_OPF_BOOK_FIELDS = ("title", "authors", "description", "publisher", "year") diff --git a/backend/indexer/scan.py b/backend/indexer/scan.py index 68ac82d..8ff3350 100644 --- a/backend/indexer/scan.py +++ b/backend/indexer/scan.py @@ -30,13 +30,17 @@ GenericMap, Token, ) +from ..services import tag_service from ._subprocess import _run_with_timeout from .categories import ( agnostic_category, folder_category_inference_disabled, guess_category, + is_one_page_folder, + is_special_collection_folder, is_system_agnostic_folder, slugify, + strip_sort_prefix, ) from .constants import ( ARCHIVE_EXTS, @@ -177,6 +181,10 @@ def _scan_books(ctx: _ScanContext, books_dir: Path) -> None: raw_name = system_dir.name is_nsfw = bool(re.search(r"\(nsfw\)", raw_name, re.IGNORECASE)) system_name = re.sub(r"\s*\(nsfw\)\s*", "", raw_name, flags=re.IGNORECASE).strip() + # Strip any leading sort-order prefix (!$%) people use to steer their file + # browser's alphabetical ordering — "!!Dungeons & Dragons" → "Dungeons & Dragons". + # This must happen before slug/name/special-collection derivation. + system_name = strip_sort_prefix(system_name) system_slug = slugify(system_name) logger.debug(f"DB: querying system '{system_slug}'") @@ -191,6 +199,10 @@ def _scan_books(ctx: _ScanContext, books_dir: Path) -> None: stats["errors"] += 1 continue is_agnostic = is_system_agnostic_folder(system_name) + is_one_page = is_one_page_folder(system_name) + # Both special collections (agnostic + one-page) use immediate-subfolder + # names as category labels rather than CATEGORY_MAP inference. + is_special = is_special_collection_folder(system_name) # Per-system opt-out: a marker file at the system root disables # folder-name category inference for just this system. system_category_off = category_inference_off or ( @@ -202,6 +214,7 @@ def _scan_books(ctx: _ScanContext, books_dir: Path) -> None: slug=system_slug, is_explicit=is_nsfw, is_system_agnostic=is_agnostic, + is_one_page=is_one_page, ) session.add(system) logger.debug(f"DB: flushing new system '{system_name}'") @@ -220,6 +233,16 @@ def _scan_books(ctx: _ScanContext, books_dir: Path) -> None: system.is_explicit = True if is_agnostic and not system.is_system_agnostic: system.is_system_agnostic = True + if is_one_page and not system.is_one_page: + system.is_one_page = True + + # Folder cover convention: a cover.*/folder.* image at the system root + # becomes the system's cover (precedence: folder > uploaded > book cover). + # Stored library-relative so it survives moves of the whole library dir. + artwork = _find_folder_artwork(str(system_dir)) + new_folder_cover = os.path.relpath(artwork, ctx.library_path) if artwork else "" + if (system.folder_cover_path or "") != new_folder_cover: + system.folder_cover_path = new_folder_cover # When scoped to a path deeper than the system dir, walk only that # subtree; otherwise walk the whole system. @@ -229,7 +252,7 @@ def _scan_books(ctx: _ScanContext, books_dir: Path) -> None: else system_dir ) stop = _scan_books_in_system( - ctx, system, system_name, system_category_off, is_agnostic, walk_root + ctx, system, system_name, system_category_off, is_special, walk_root ) if stop: return @@ -240,10 +263,14 @@ def _scan_books_in_system( system: GameSystem, system_name: str, system_category_off: bool, - is_agnostic: bool, + is_special_collection: bool, walk_root: Path, ) -> bool: - """Walk one system's tree and register its books. Returns True if stop requested.""" + """Walk one system's tree and register its books. Returns True if stop requested. + + ``is_special_collection`` is True for the system-agnostic and one-page + collections, which label categories by immediate subfolder name. + """ session = ctx.session ignore = ctx.ignore stats = ctx.stats @@ -309,7 +336,7 @@ def _scan_books_in_system( system, system_name, system_category_off, - is_agnostic, + is_special_collection, root, filename, filepath, @@ -334,7 +361,7 @@ def _register_book( system: GameSystem, system_name: str, system_category_off: bool, - is_agnostic: bool, + is_special_collection: bool, root: str, filename: str, filepath: str, @@ -354,7 +381,16 @@ def _register_book( # requested (modes "missing"/"replace") — see _apply_opf_to_book. if ctx.metadata_mode in ("missing", "replace"): opf_meta = _find_opf_meta(root, filename) - if _apply_opf_to_book(existing, opf_meta, ctx.metadata_mode): + changed = _apply_opf_to_book(existing, opf_meta, ctx.metadata_mode) + # OPF ``tags`` are shared tags (issue #235), applied via the service. + # In "missing" mode only fill when the book has no tags yet. + opf_tags = opf_meta.get("tags") + if opf_tags: + current = tag_service.display_tags_for_resource(session, "book", existing.id) + if ctx.metadata_mode == "replace" or not current: + tag_service.set_resource_tags(session, "book", existing.id, opf_tags) + changed = True + if changed: logger.debug(f"Refreshing metadata for '{filename}' (mode={ctx.metadata_mode})") try: _run_with_timeout( @@ -384,7 +420,7 @@ def _register_book( if system_category_off: category = UNCATEGORIZED - elif is_agnostic: + elif is_special_collection: category = agnostic_category(relative_path) else: category = guess_category(relative_path) @@ -420,7 +456,6 @@ def _register_book( description=opf_meta.get("description"), publisher=opf_meta.get("publisher"), year=opf_meta.get("year"), - tags=opf_meta.get("tags"), ) # Commit the book record first so that if a subsequent @@ -430,6 +465,11 @@ def _register_book( logger.debug(f"DB: committing new book '{filename}'") try: _run_with_timeout(session.commit, _DB_TIMEOUT, f"commit book '{filepath}'") + # OPF ``subjects`` become shared tags on the book (issue #235). + opf_tags = opf_meta.get("tags") + if opf_tags: + tag_service.set_resource_tags(session, "book", book.id, opf_tags) + session.commit() stats["new_books"] += 1 logger.info(f"Added book: {title} ({category}) in {system_name}") except TimeoutError as e: diff --git a/backend/indexer/tags.py b/backend/indexer/tags.py index 37e3a95..1efdcba 100644 --- a/backend/indexer/tags.py +++ b/backend/indexer/tags.py @@ -20,6 +20,7 @@ Token, TokenFolder, ) +from ..services import tag_service from .categories import slugify from .metadata import resolve_collection_dir @@ -43,13 +44,17 @@ def _load_tags_json(folder_path: str) -> dict: result = {} for key, val in raw.items(): if isinstance(val, list): + # Keep the entered casing (dedupe by lowercased key). It becomes + # the default display for a brand-new tag; the tag service never + # overwrites an existing tag's display, so a rescan is safe. seen: set[str] = set() normalized = [] for t in val: - lowered = str(t).strip().lower() + stripped = str(t).strip() + lowered = stripped.lower() if lowered and lowered not in seen: seen.add(lowered) - normalized.append(lowered) + normalized.append(stripped) result[key] = normalized return result except Exception as exc: @@ -57,6 +62,33 @@ def _load_tags_json(folder_path: str) -> dict: return {} +def _apply_folder_tags( + session: Session, + folder_model: type, + folder_rel: str, + tags: list[str], + resource_type: str, +) -> None: + """Additively apply ``tags.json`` tags to a folder record. + + The library is read-only, so ``tags.json`` only *adds* tags: catalog rows are + registered (new tags keep their entered casing, existing displays are left + alone) and the folder's stored internal keys are the union of what's there and + the new keys — user-set folder tags are never removed by a rescan. + """ + internals = tag_service.register_folder_tags(session, tags, category=resource_type) + record = session.query(folder_model).filter_by(path=folder_rel).first() + if record: + existing = [tag_service.normalize_internal(t) for t in (record.tags or [])] + merged = list(existing) + for key in internals: + if key not in merged: + merged.append(key) + record.tags = merged + else: + session.add(folder_model(path=folder_rel, tags=internals)) + + def _within_scope(path: Path, scope_dir: Path | None) -> bool: """Return True if `path` is the scope dir or lives under it (or scope is None).""" if scope_dir is None: @@ -81,6 +113,8 @@ def _apply_tags_from_library( "tokens": (TokenFolder, Token), "audio": (AudioFolder, Audio), } + # Section name → shared-tag resource_type (issue #235). + _section_resource = {"maps": "map", "tokens": "token", "audio": "audio"} for section in ("maps", "tokens", "audio"): section_dir = resolve_collection_dir(library, section) if not section_dir.exists(): @@ -110,30 +144,24 @@ def _apply_tags_from_library( if not tags: continue + rtype = _section_resource[section] if key == ".": folder_rel = str(os.path.relpath(root, section_dir)) - record = session.query(folder_model).filter_by(path=folder_rel).first() - if record: - record.tags = tags - else: - session.add(folder_model(path=folder_rel, tags=tags)) - logger.debug(f"tags.json: folder {folder_rel} ← {tags}") + _apply_folder_tags(session, folder_model, folder_rel, tags, rtype) + logger.debug(f"tags.json: folder {folder_rel} += {tags}") else: target = root_path / key if target.is_dir(): folder_rel = str(os.path.relpath(target, section_dir)) - record = session.query(folder_model).filter_by(path=folder_rel).first() - if record: - record.tags = tags - else: - session.add(folder_model(path=folder_rel, tags=tags)) - logger.debug(f"tags.json: folder {folder_rel} ← {tags}") + _apply_folder_tags(session, folder_model, folder_rel, tags, rtype) + logger.debug(f"tags.json: folder {folder_rel} += {tags}") else: file_rel = os.path.relpath(target, library_path) record = session.query(file_model).filter_by(relative_path=file_rel).first() if record: - record.tags = tags - logger.debug(f"tags.json: file {file_rel} ← {tags}") + # Additive: tags.json only adds item tags (issue #235). + tag_service.add_resource_tags(session, rtype, record.id, tags) + logger.debug(f"tags.json: file {file_rel} += {tags}") else: logger.debug(f"tags.json: no record found for {file_rel}") @@ -158,7 +186,8 @@ def _apply_tags_from_library( system_slug = slugify(system_dir.name) system = session.query(GameSystem).filter_by(slug=system_slug).first() if system: - system.tags = tags - logger.debug(f"tags.json: system {system_dir.name} ← {tags}") + # Additive: tags.json only adds system tags (issue #235). + tag_service.add_resource_tags(session, "system", system.id, tags) + logger.debug(f"tags.json: system {system_dir.name} += {tags}") session.commit() diff --git a/backend/main.py b/backend/main.py index 0f97bbc..5759bfe 100644 --- a/backend/main.py +++ b/backend/main.py @@ -34,13 +34,16 @@ favorites as favorites_router, library as library_router, logs as logs_router, + lookups as lookups_router, maintenance as maintenance_router, maps as maps_router, oidc as oidc_router, opds as opds_router, + saved_filters as saved_filters_router, search as search_router, settings as settings_router, systems as systems_router, + tags as tags_router, tokens as tokens_router, users as users_router, ) @@ -228,6 +231,7 @@ def health(): api.include_router(users_router.router) api.include_router(systems_router.router) api.include_router(books_router.router) +api.include_router(lookups_router.router) api.include_router(maps_router.router) api.include_router(tokens_router.router) api.include_router(audio_router.router) @@ -235,6 +239,8 @@ def health(): api.include_router(search_router.router) api.include_router(campaigns_router.router) api.include_router(favorites_router.router) +api.include_router(tags_router.router) +api.include_router(saved_filters_router.router) api.include_router(bookmarks_router.router) api.include_router(downloads_router.router) api.include_router(export_router.router) diff --git a/backend/migrations/versions/0004_expand_metadata.py b/backend/migrations/versions/0004_expand_metadata.py new file mode 100644 index 0000000..7401324 --- /dev/null +++ b/backend/migrations/versions/0004_expand_metadata.py @@ -0,0 +1,256 @@ +"""expand system & book metadata; genre/system-family lookups (issue #202) + +Additive schema changes only (SQLite-safe — no column drops): + +game_systems: genres, dice_materials, system_family, license, year, urls, + character_builder_urls, is_one_page +books: artists, genres, isbn, version, language, month, day, urls + +New lookup tables ``genres`` (tiered via parent_id) and ``system_families``, +seeded with defaults. Backfills the new multi-value columns from the legacy +single-value ones (game_systems.genre / .character_builder_url, +books.publisher_url). + +Revision ID: 6be3e9a796c4 +Revises: b2e5d3f0c8a1 +Create Date: 2026-07-25 00:00:00.000000+00:00 + +""" +import json +import uuid +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +from backend.models.lookup_defaults import DEFAULT_GENRES, DEFAULT_SYSTEM_FAMILIES + + +# revision identifiers, used by Alembic. +revision: str = "6be3e9a796c4" +down_revision: Union[str, None] = "b2e5d3f0c8a1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _columns(table: str) -> set: + return {c["name"] for c in inspect(op.get_bind()).get_columns(table)} + + +def _tables() -> set: + return set(inspect(op.get_bind()).get_table_names()) + + +def _add(table: str, column: sa.Column) -> None: + """Add a column only if it isn't already present (idempotent retries).""" + if column.name not in _columns(table): + op.add_column(table, column) + + +def upgrade() -> None: + op.execute("DROP TABLE IF EXISTS _alembic_tmp_books") + op.execute("DROP TABLE IF EXISTS _alembic_tmp_game_systems") + + # --- game_systems columns --- + _add("game_systems", sa.Column("genres", sa.JSON(), nullable=True)) + _add("game_systems", sa.Column("dice_materials", sa.JSON(), nullable=True)) + _add( + "game_systems", + sa.Column("system_family", sa.String(length=150), nullable=True, server_default=""), + ) + _add( + "game_systems", + sa.Column("license", sa.String(length=100), nullable=True, server_default=""), + ) + _add("game_systems", sa.Column("year", sa.Integer(), nullable=True)) + _add("game_systems", sa.Column("urls", sa.JSON(), nullable=True)) + _add("game_systems", sa.Column("character_builder_urls", sa.JSON(), nullable=True)) + _add( + "game_systems", + sa.Column("is_one_page", sa.Boolean(), nullable=True, server_default=sa.text("0")), + ) + + # --- books columns --- + _add("books", sa.Column("artists", sa.JSON(), nullable=True)) + _add("books", sa.Column("genres", sa.JSON(), nullable=True)) + _add("books", sa.Column("isbn", sa.String(length=20), nullable=True, server_default="")) + _add("books", sa.Column("version", sa.String(length=50), nullable=True, server_default="")) + _add("books", sa.Column("language", sa.String(length=20), nullable=True, server_default="")) + _add("books", sa.Column("month", sa.Integer(), nullable=True)) + _add("books", sa.Column("day", sa.Integer(), nullable=True)) + _add("books", sa.Column("urls", sa.JSON(), nullable=True)) + + # --- lookup tables --- + tables = _tables() + if "genres" not in tables: + op.create_table( + "genres", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("parent_id", sa.String(length=36), nullable=True), + sa.Column("is_default", sa.Boolean(), nullable=True), + sa.Column("sort_order", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["parent_id"], ["genres.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_index("ix_genres_parent_id", "genres", ["parent_id"]) + if "system_families" not in tables: + op.create_table( + "system_families", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=150), nullable=False), + sa.Column("is_default", sa.Boolean(), nullable=True), + sa.Column("sort_order", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + + _seed_lookups() + _backfill() + + +def _seed_lookups() -> None: + """Insert default genres/families. Skips names that already exist.""" + bind = op.get_bind() + + existing_genres = { + row[0] for row in bind.execute(sa.text("SELECT name FROM genres")).fetchall() + } + order = 0 + for name, children in DEFAULT_GENRES: + order += 1 + parent_id = _ensure_genre(bind, existing_genres, name, None, order) + child_order = 0 + for child_name, _grandchildren in children: + child_order += 1 + _ensure_genre(bind, existing_genres, child_name, parent_id, child_order) + + existing_families = { + row[0] + for row in bind.execute(sa.text("SELECT name FROM system_families")).fetchall() + } + for idx, fam in enumerate(DEFAULT_SYSTEM_FAMILIES): + if fam in existing_families: + continue + bind.execute( + sa.text( + "INSERT INTO system_families (id, name, is_default, sort_order) " + "VALUES (:id, :name, 1, :sort_order)" + ), + {"id": str(uuid.uuid4()), "name": fam, "sort_order": idx}, + ) + + +def _ensure_genre(bind, existing: set, name: str, parent_id, sort_order: int) -> str: + """Insert a genre if absent; return its id either way.""" + if name in existing: + row = bind.execute( + sa.text("SELECT id FROM genres WHERE name = :name"), {"name": name} + ).fetchone() + return row[0] + new_id = str(uuid.uuid4()) + bind.execute( + sa.text( + "INSERT INTO genres (id, name, parent_id, is_default, sort_order) " + "VALUES (:id, :name, :parent_id, 1, :sort_order)" + ), + {"id": new_id, "name": name, "parent_id": parent_id, "sort_order": sort_order}, + ) + existing.add(name) + return new_id + + +def _backfill() -> None: + """Populate new multi-value columns from the legacy single-value ones.""" + bind = op.get_bind() + + # game_systems.genre -> genres; character_builder_url -> character_builder_urls + rows = bind.execute( + sa.text( + "SELECT id, genre, character_builder_url, genres, " + "character_builder_urls, urls FROM game_systems" + ) + ).fetchall() + for gid, genre, cb_url, genres, cb_urls, urls in rows: + updates = {} + if _empty(genres) and genre: + updates["genres"] = json.dumps([genre]) + if _empty(cb_urls) and cb_url: + updates["character_builder_urls"] = json.dumps( + [{"label": "", "url": cb_url}] + ) + if _empty(genres) and _empty(urls): + updates.setdefault("urls", json.dumps([])) + # Ensure JSON list columns are never left NULL. + _default_json(updates, "genres", genres) + _default_json(updates, "dice_materials", None) + _default_json(updates, "urls", urls) + _default_json(updates, "character_builder_urls", cb_urls) + if updates: + _apply_update(bind, "game_systems", gid, updates) + + # books.publisher_url -> urls + rows = bind.execute( + sa.text("SELECT id, publisher_url, urls, genres, artists FROM books") + ).fetchall() + for bid, pub_url, urls, genres, artists in rows: + updates = {} + if _empty(urls) and pub_url: + updates["urls"] = json.dumps([{"label": "Publisher", "url": pub_url}]) + _default_json(updates, "urls", urls) + _default_json(updates, "genres", genres) + _default_json(updates, "artists", artists) + if updates: + _apply_update(bind, "books", bid, updates) + + +def _empty(raw) -> bool: + if raw is None: + return True + try: + val = json.loads(raw) if isinstance(raw, str) else raw + except (ValueError, TypeError): + return True + return not val + + +def _default_json(updates: dict, key: str, current) -> None: + """Ensure a JSON list column gets an empty-list default when NULL.""" + if key not in updates and current is None: + updates[key] = json.dumps([]) + + +def _apply_update(bind, table: str, row_id: str, updates: dict) -> None: + set_clause = ", ".join(f"{k} = :{k}" for k in updates) + params = dict(updates) + params["row_id"] = row_id + bind.execute( + sa.text(f"UPDATE {table} SET {set_clause} WHERE id = :row_id"), params + ) + + +def downgrade() -> None: + tables = _tables() + if "system_families" in tables: + op.drop_table("system_families") + if "genres" in tables: + op.drop_index("ix_genres_parent_id", table_name="genres") + op.drop_table("genres") + + for col in ("artists", "genres", "isbn", "version", "language", "month", "day", "urls"): + if col in _columns("books"): + op.drop_column("books", col) + for col in ( + "genres", + "dice_materials", + "system_family", + "license", + "year", + "urls", + "character_builder_urls", + "is_one_page", + ): + if col in _columns("game_systems"): + op.drop_column("game_systems", col) diff --git a/backend/migrations/versions/0005_saved_filters.py b/backend/migrations/versions/0005_saved_filters.py new file mode 100644 index 0000000..019a220 --- /dev/null +++ b/backend/migrations/versions/0005_saved_filters.py @@ -0,0 +1,52 @@ +"""saved_filters: per-user named sort/filter presets with a per-scope default + +Adds the ``saved_filters`` table backing server-side saved filters for the +library scopes (systems/books/maps/tokens/audio). One preset per (user, scope) +may be the default the user lands on. + +Revision ID: 96927e7cb35e +Revises: 6be3e9a796c4 +Create Date: 2026-07-25 00:00:00.000000+00:00 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +# revision identifiers, used by Alembic. +revision: str = "96927e7cb35e" +down_revision: Union[str, None] = "6be3e9a796c4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _tables() -> set: + return set(inspect(op.get_bind()).get_table_names()) + + +def upgrade() -> None: + if "saved_filters" not in _tables(): + op.create_table( + "saved_filters", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("user_id", sa.String(length=36), nullable=False), + sa.Column("scope", sa.String(length=20), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("state", sa.JSON(), nullable=True), + sa.Column("is_default", sa.Boolean(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "scope", "name"), + ) + op.create_index("ix_saved_filters_user_id", "saved_filters", ["user_id"]) + + +def downgrade() -> None: + if "saved_filters" in _tables(): + op.drop_index("ix_saved_filters_user_id", table_name="saved_filters") + op.drop_table("saved_filters") diff --git a/backend/migrations/versions/0006_parent_system_licenses.py b/backend/migrations/versions/0006_parent_system_licenses.py new file mode 100644 index 0000000..8c62a67 --- /dev/null +++ b/backend/migrations/versions/0006_parent_system_licenses.py @@ -0,0 +1,137 @@ +"""parent_system/edition + license & dice/material lookups + +Adds: + * game_systems.parent_system, game_systems.edition + * books.license (per-book override of the system license) + * parent_systems, licenses, dice_materials lookup tables (seeded) + +All operations are idempotent so partial/retried runs are safe. + +Revision ID: 873d3303ba93 +Revises: 96927e7cb35e +Create Date: 2026-07-26 00:00:00.000000+00:00 + +""" +import uuid +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +from backend.models.lookup_defaults import ( + DEFAULT_DICE_MATERIALS, + DEFAULT_LICENSES, + DEFAULT_PARENT_SYSTEMS, +) + + +# revision identifiers, used by Alembic. +revision: str = "873d3303ba93" +down_revision: Union[str, None] = "96927e7cb35e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _columns(table: str) -> set: + return {c["name"] for c in inspect(op.get_bind()).get_columns(table)} + + +def _tables() -> set: + return set(inspect(op.get_bind()).get_table_names()) + + +def _add(table: str, column: sa.Column) -> None: + if column.name not in _columns(table): + op.add_column(table, column) + + +def upgrade() -> None: + # --- new columns --- + _add( + "game_systems", + sa.Column("parent_system", sa.String(length=150), nullable=True, server_default=""), + ) + _add( + "game_systems", + sa.Column("edition", sa.String(length=80), nullable=True, server_default=""), + ) + _add( + "books", + sa.Column("license", sa.String(length=100), nullable=True, server_default=""), + ) + + tables = _tables() + for name in ("parent_systems", "licenses"): + if name not in tables: + op.create_table( + name, + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=150), nullable=False), + sa.Column("is_default", sa.Boolean(), nullable=True), + sa.Column("sort_order", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + if "dice_materials" not in tables: + op.create_table( + "dice_materials", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("group", sa.String(length=60), nullable=True, server_default="Custom"), + sa.Column("is_default", sa.Boolean(), nullable=True), + sa.Column("sort_order", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + + _seed_lookups() + + +def _seed_named(bind, table: str, names: Sequence[str]) -> None: + """Seed a simple (id, name, is_default, sort_order) lookup, skipping dupes.""" + existing = { + row[0] for row in bind.execute(sa.text(f"SELECT name FROM {table}")).fetchall() + } + for idx, name in enumerate(names): + if name in existing: + continue + bind.execute( + sa.text( + f"INSERT INTO {table} (id, name, is_default, sort_order) " + "VALUES (:id, :name, 1, :sort_order)" + ), + {"id": str(uuid.uuid4()), "name": name, "sort_order": idx}, + ) + + +def _seed_lookups() -> None: + bind = op.get_bind() + _seed_named(bind, "parent_systems", DEFAULT_PARENT_SYSTEMS) + _seed_named(bind, "licenses", DEFAULT_LICENSES) + + existing_dice = { + row[0] for row in bind.execute(sa.text("SELECT name FROM dice_materials")).fetchall() + } + for idx, (group, name) in enumerate(DEFAULT_DICE_MATERIALS): + if name in existing_dice: + continue + bind.execute( + sa.text( + 'INSERT INTO dice_materials (id, name, "group", is_default, sort_order) ' + "VALUES (:id, :name, :group, 1, :sort_order)" + ), + {"id": str(uuid.uuid4()), "name": name, "group": group, "sort_order": idx}, + ) + + +def downgrade() -> None: + tables = _tables() + for name in ("dice_materials", "licenses", "parent_systems"): + if name in tables: + op.drop_table(name) + if "license" in _columns("books"): + op.drop_column("books", "license") + for col in ("parent_system", "edition"): + if col in _columns("game_systems"): + op.drop_column("game_systems", col) diff --git a/backend/migrations/versions/0007_system_folder_cover.py b/backend/migrations/versions/0007_system_folder_cover.py new file mode 100644 index 0000000..b4b50ff --- /dev/null +++ b/backend/migrations/versions/0007_system_folder_cover.py @@ -0,0 +1,41 @@ +"""system folder-cover path + +Adds game_systems.folder_cover_path — the library-relative path to a +cover.*/folder.* image found at a system's folder root by the scanner. It takes +precedence over the admin-uploaded cover_image, which beats the cover_book_id +fallback. Idempotent. + +Revision ID: 1537716d5347 +Revises: 873d3303ba93 +Create Date: 2026-07-26 00:00:00.000000+00:00 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +# revision identifiers, used by Alembic. +revision: str = "1537716d5347" +down_revision: Union[str, None] = "873d3303ba93" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _columns(table: str) -> set: + return {c["name"] for c in inspect(op.get_bind()).get_columns(table)} + + +def upgrade() -> None: + if "folder_cover_path" not in _columns("game_systems"): + op.add_column( + "game_systems", + sa.Column("folder_cover_path", sa.String(length=1000), nullable=True, server_default=""), + ) + + +def downgrade() -> None: + if "folder_cover_path" in _columns("game_systems"): + op.drop_column("game_systems", "folder_cover_path") diff --git a/backend/migrations/versions/0008_shared_tags.py b/backend/migrations/versions/0008_shared_tags.py new file mode 100644 index 0000000..2278aa5 --- /dev/null +++ b/backend/migrations/versions/0008_shared_tags.py @@ -0,0 +1,173 @@ +"""shared cross-resource tags + +Introduces the application-wide tag model (issue #235): a ``tags`` table +(internal match key + display label) and a polymorphic ``resource_tags`` join. +Backfills both from the existing per-row JSON ``tags`` arrays on systems, books, +maps, tokens, and audio. The JSON columns are intentionally left in place for now +(parallel-run safety); a later migration drops them once nothing reads them. + +Display value for a migrated tag defaults to the first non-empty original casing +seen for that internal key, else the internal key itself. Idempotent: re-running +neither duplicates tables nor re-links resources. + +Revision ID: c7a9e1f2b8d4 +Revises: 1537716d5347 +Create Date: 2026-07-27 00:00:00.000000+00:00 + +""" +import json +import uuid +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect, text + + +# revision identifiers, used by Alembic. +revision: str = "c7a9e1f2b8d4" +down_revision: Union[str, None] = "1537716d5347" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +# (source table, resource_type) pairs holding a JSON ``tags`` column + string id. +_SOURCES = [ + ("game_systems", "system"), + ("books", "book"), + ("generic_maps", "map"), + ("tokens", "token"), + ("audio", "audio"), +] + + +def _title_case(text_value: str) -> str: + """Title-case each whitespace-separated word (keeps existing inner casing per + word intact, only upper-casing the first letter). "draw steel" → "Draw Steel".""" + return " ".join(w[:1].upper() + w[1:] if w else w for w in text_value.split(" ")) + + +def _has_table(bind, name: str) -> bool: + return inspect(bind).has_table(name) + + +def _columns(bind, table: str) -> set: + return {c["name"] for c in inspect(bind).get_columns(table)} + + +def upgrade() -> None: + bind = op.get_bind() + + if not _has_table(bind, "tags"): + op.create_table( + "tags", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("internal", sa.String(length=200), nullable=False), + sa.Column("display", sa.String(length=200), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.UniqueConstraint("internal", name="uq_tags_internal"), + ) + + if not _has_table(bind, "resource_tags"): + op.create_table( + "resource_tags", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("tag_id", sa.String(length=36), nullable=False), + sa.Column("resource_type", sa.String(length=20), nullable=False), + sa.Column("resource_id", sa.String(length=36), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["tag_id"], ["tags.id"], ondelete="CASCADE"), + sa.UniqueConstraint( + "tag_id", "resource_type", "resource_id", name="uq_resource_tag" + ), + ) + op.create_index( + "ix_resource_tags_resource", + "resource_tags", + ["resource_type", "resource_id"], + ) + op.create_index("ix_resource_tags_tag", "resource_tags", ["tag_id"]) + + _backfill(bind) + + +def _backfill(bind) -> None: + # internal -> tag id (cache within this migration run). + tag_ids: dict[str, str] = { + row[0]: row[1] + for row in bind.execute(text("SELECT internal, id FROM tags")).fetchall() + } + + def ensure_tag(raw: str) -> str | None: + internal = str(raw).strip().lower() + if not internal: + return None + if internal in tag_ids: + return tag_ids[internal] + tid = str(uuid.uuid4()) + stripped = str(raw).strip() + # Legacy JSON tags were stored lowercased, so an all-lowercase value has no + # meaningful casing — default the display to Title Case. A value that + # already carries uppercase is kept as the user entered it. + display = stripped if stripped != stripped.lower() else _title_case(stripped) + display = display or internal + bind.execute( + text( + "INSERT INTO tags (id, internal, display, created_at) " + "VALUES (:id, :internal, :display, CURRENT_TIMESTAMP)" + ), + {"id": tid, "internal": internal, "display": display}, + ) + tag_ids[internal] = tid + return tid + + for table, resource_type in _SOURCES: + if not _has_table(bind, table) or "tags" not in _columns(bind, table): + continue + rows = bind.execute( + text(f"SELECT id, tags FROM {table} WHERE tags IS NOT NULL") + ).fetchall() + for res_id, raw in rows: + try: + tags = json.loads(raw) if isinstance(raw, str) else raw + except (ValueError, TypeError): + continue + if not isinstance(tags, list): + continue + for raw_tag in tags: + tid = ensure_tag(raw_tag) + if tid is None: + continue + # Idempotent link insert (skip if this resource already linked). + exists = bind.execute( + text( + "SELECT 1 FROM resource_tags WHERE tag_id = :tid " + "AND resource_type = :rt AND resource_id = :rid" + ), + {"tid": tid, "rt": resource_type, "rid": res_id}, + ).first() + if exists: + continue + bind.execute( + text( + "INSERT INTO resource_tags " + "(id, tag_id, resource_type, resource_id, created_at) " + "VALUES (:id, :tid, :rt, :rid, CURRENT_TIMESTAMP)" + ), + { + "id": str(uuid.uuid4()), + "tid": tid, + "rt": resource_type, + "rid": res_id, + }, + ) + + +def downgrade() -> None: + bind = op.get_bind() + if _has_table(bind, "resource_tags"): + op.drop_index("ix_resource_tags_tag", table_name="resource_tags") + op.drop_index("ix_resource_tags_resource", table_name="resource_tags") + op.drop_table("resource_tags") + if _has_table(bind, "tags"): + op.drop_table("tags") diff --git a/backend/migrations/versions/0009_drop_json_item_tags.py b/backend/migrations/versions/0009_drop_json_item_tags.py new file mode 100644 index 0000000..9894dc3 --- /dev/null +++ b/backend/migrations/versions/0009_drop_json_item_tags.py @@ -0,0 +1,78 @@ +"""drop legacy JSON tag columns from item tables + +Item tags moved to the shared-tag tables (``tags`` / ``resource_tags``) in 0008, +and all reads/writes now go through them (issue #235). This drops the now-unused +JSON ``tags`` column from the five item tables. Folder tables keep their JSON +``tags`` (folder tagging is a separate, unchanged feature). Idempotent. + +The 0008 backfill already copied every JSON tag into the shared tables, so no data +is lost. ``downgrade`` re-adds the (empty) columns for schema symmetry. + +Revision ID: e2b8c6a4d9f1 +Revises: c7a9e1f2b8d4 +Create Date: 2026-07-27 00:00:00.000000+00:00 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect, text + + +revision: str = "e2b8c6a4d9f1" +down_revision: Union[str, None] = "c7a9e1f2b8d4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +# Item tables whose per-row JSON ``tags`` column is dropped (folder tables keep it). +_ITEM_TABLES = ["game_systems", "books", "generic_maps", "tokens", "audio"] + + +def _has_column(table: str, column: str) -> bool: + insp = inspect(op.get_bind()) + if not insp.has_table(table): + return False + return column in {c["name"] for c in insp.get_columns(table)} + + +def _drop_leftover_tmp(table: str) -> None: + """Remove any ``_alembic_tmp_`` left behind by a previously interrupted + batch migration. SQLite's batch (copy-and-move) column change creates this temp + table; if an earlier run died mid-flight it lingers and makes every retry fail + with "table _alembic_tmp_
already exists". Dropping it first makes this + migration self-healing. + """ + op.get_bind().execute(text(f'DROP TABLE IF EXISTS "_alembic_tmp_{table}"')) + + +def _supports_native_drop(bind) -> bool: + """SQLite gained ``ALTER TABLE ... DROP COLUMN`` in 3.35.0 (2021). When + available we use it directly — no table rebuild, no ``_alembic_tmp_*`` temp + table, so no cross-worker collision or leftover-temp-table failure mode.""" + if bind.dialect.name != "sqlite": + return True # other backends support DROP COLUMN natively + version = getattr(bind.dialect, "server_version_info", None) + return bool(version) and version >= (3, 35, 0) + + +def upgrade() -> None: + bind = op.get_bind() + for table in _ITEM_TABLES: + # Clear any leftover temp table from a previously interrupted attempt on + # an older version of this migration, so a retry always starts clean. + _drop_leftover_tmp(table) + if not _has_column(table, "tags"): + continue + if _supports_native_drop(bind): + bind.execute(text(f'ALTER TABLE "{table}" DROP COLUMN "tags"')) + else: + with op.batch_alter_table(table) as batch: + batch.drop_column("tags") + + +def downgrade() -> None: + for table in _ITEM_TABLES: + if not _has_column(table, "tags"): + op.add_column(table, sa.Column("tags", sa.JSON(), nullable=True)) diff --git a/backend/migrations/versions/0010_titlecase_migrated_tag_displays.py b/backend/migrations/versions/0010_titlecase_migrated_tag_displays.py new file mode 100644 index 0000000..a7bad5b --- /dev/null +++ b/backend/migrations/versions/0010_titlecase_migrated_tag_displays.py @@ -0,0 +1,47 @@ +"""title-case display for migrated tags + +The 0008 backfill copied the legacy JSON tag values (which were stored lowercased) +straight into ``tags.display``, leaving migrated tags with an all-lowercase display. +This retitles those: any tag whose display is still all-lowercase (i.e. it was never +given a proper display casing) is Title-Cased. Tags a user has explicitly cased are +left untouched. Idempotent. + +Revision ID: f3c1a7d2e6b8 +Revises: e2b8c6a4d9f1 +Create Date: 2026-07-27 00:00:00.000000+00:00 + +""" +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + + +revision: str = "f3c1a7d2e6b8" +down_revision: Union[str, None] = "e2b8c6a4d9f1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _title_case(value: str) -> str: + return " ".join(w[:1].upper() + w[1:] if w else w for w in value.split(" ")) + + +def upgrade() -> None: + bind = op.get_bind() + if not inspect(bind).has_table("tags"): + return + rows = bind.execute(text("SELECT id, display FROM tags")).fetchall() + for tid, display in rows: + if display and display == display.lower(): + titled = _title_case(display) + if titled != display: + bind.execute( + text("UPDATE tags SET display = :d WHERE id = :id"), + {"d": titled, "id": tid}, + ) + + +def downgrade() -> None: + # One-way data normalization; nothing to revert. + pass diff --git a/backend/migrations/versions/0011_tag_category.py b/backend/migrations/versions/0011_tag_category.py new file mode 100644 index 0000000..2641d71 --- /dev/null +++ b/backend/migrations/versions/0011_tag_category.py @@ -0,0 +1,63 @@ +"""tag category + +Adds ``tags.category`` — the single resource type a tag belongs to, or ``shared`` +when it spans more than one (issue #235 follow-up). Backfills from existing +``resource_tags`` usage: a tag used on exactly one resource type takes that type; +a tag used across multiple types (or on none) becomes ``shared``. Idempotent. + +Revision ID: a4d8f1c9e2b7 +Revises: f3c1a7d2e6b8 +Create Date: 2026-07-28 00:00:00.000000+00:00 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect, text + + +revision: str = "a4d8f1c9e2b7" +down_revision: Union[str, None] = "f3c1a7d2e6b8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _has_column(table: str, column: str) -> bool: + insp = inspect(op.get_bind()) + if not insp.has_table(table): + return False + return column in {c["name"] for c in insp.get_columns(table)} + + +def upgrade() -> None: + bind = op.get_bind() + if not inspect(bind).has_table("tags"): + return + if not _has_column("tags", "category"): + op.add_column( + "tags", + sa.Column("category", sa.String(length=20), nullable=False, server_default="shared"), + ) + + # Backfill: distinct resource types per tag from resource_tags. + rows = bind.execute( + text( + "SELECT tag_id, resource_type FROM resource_tags GROUP BY tag_id, resource_type" + ) + ).fetchall() + types_by_tag: dict[str, set] = {} + for tag_id, rtype in rows: + types_by_tag.setdefault(tag_id, set()).add(rtype) + + for tag_id, types in types_by_tag.items(): + category = next(iter(types)) if len(types) == 1 else "shared" + bind.execute( + text("UPDATE tags SET category = :c WHERE id = :id"), + {"c": category, "id": tag_id}, + ) + + +def downgrade() -> None: + if _has_column("tags", "category"): + op.drop_column("tags", "category") diff --git a/backend/models/__init__.py b/backend/models/__init__.py index c3eec7d..5bc937e 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -22,10 +22,20 @@ WikiPageShare, ) from .db import init_db -from .library import Book, BookFolder, GameSystem +from .library import ( + Book, + BookFolder, + DiceMaterial, + GameSystem, + Genre, + License, + ParentSystem, + SystemFamily, +) from .media import Audio, AudioFolder, GenericMap, MapFolder, Token, TokenFolder from .settings import AppSetting -from .users import Bookmark, Favorite, User +from .tags import RESOURCE_TYPES, SHARED_CATEGORY, TAG_CATEGORIES, ResourceTag, Tag +from .users import Bookmark, Favorite, SavedFilter, User __all__ = [ "Base", @@ -34,6 +44,11 @@ "GameSystem", "Book", "BookFolder", + "Genre", + "SystemFamily", + "ParentSystem", + "License", + "DiceMaterial", # Media "GenericMap", "MapFolder", @@ -45,6 +60,7 @@ "User", "Bookmark", "Favorite", + "SavedFilter", # Campaigns "Campaign", "CampaignMember", @@ -62,4 +78,10 @@ "SessionAvailability", # Settings "AppSetting", + # Tags + "Tag", + "ResourceTag", + "RESOURCE_TYPES", + "SHARED_CATEGORY", + "TAG_CATEGORIES", ] diff --git a/backend/models/db.py b/backend/models/db.py index a210a18..3a2dd4f 100644 --- a/backend/models/db.py +++ b/backend/models/db.py @@ -29,19 +29,25 @@ def _normalize_tags_in_db(conn: Connection) -> None: - """Lower-case and deduplicate tags in all tag-bearing tables.""" + """Lower-case and deduplicate the JSON ``tags`` on the folder tables. + + Item tags (systems/books/maps/tokens/audio) moved to the shared-tag tables + (issue #235) and their JSON columns are dropped, so only the folder tables — + which still carry plain JSON tag lists — are normalized here. Tables without a + ``tags`` column are skipped defensively. + """ tables = [ - "game_systems", - "books", "book_folders", - "generic_maps", "map_folders", - "tokens", "token_folders", - "audio", "audio_folders", ] + inspector = inspect(conn) for table in tables: + if not inspector.has_table(table): + continue + if "tags" not in {c["name"] for c in inspector.get_columns(table)}: + continue rows = conn.execute( text(f"SELECT rowid, tags FROM {table} WHERE tags IS NOT NULL") ).fetchall() diff --git a/backend/models/library.py b/backend/models/library.py index b296e4d..2193de7 100644 --- a/backend/models/library.py +++ b/backend/models/library.py @@ -25,13 +25,39 @@ class GameSystem(Base): slug = Column(String(255), unique=True, nullable=False) description = Column(Text, default="") publishers = Column(JSON, default=list) + # Legacy single-value URL column. Kept for backward compatibility; new code + # reads/writes the multi-value ``character_builder_urls`` list instead. character_builder_url = Column(String(512), default="") + # Admin-uploaded cover: bare filename stored under DATA_PATH/system_covers/. cover_image = Column(String(512), default="") + # Library-relative path to a cover.*/folder.* image found at the system's + # folder root by the scanner. Takes precedence over the uploaded cover_image, + # which in turn beats the cover_book_id fallback. + folder_cover_path = Column(String(1000), default="") cover_book_id = Column(String(36), nullable=True) - tags = Column(JSON, default=list) + # Legacy single-value genre column. Superseded by the ``genres`` JSON list; + # kept so old databases keep working and the backfill has a source. genre = Column(String(100), default="") + # Multi-value metadata (issue #202). + genres = Column(JSON, default=list) + dice_materials = Column(JSON, default=list) + system_family = Column(String(150), default="") + # Parent-system / edition hierarchy: a system_family (e.g. "d20 System") may + # contain several parent_systems (e.g. "Dungeons & Dragons"), each of which + # has editions (e.g. "5e"). ``parent_system`` + ``edition`` combine for + # display ("Cyberpunk" + "Red" → "Cyberpunk Red"). + parent_system = Column(String(150), default="") + edition = Column(String(80), default="") + license = Column(String(100), default="") + year = Column(Integer, nullable=True) + # Labeled link lists: ``[{"label": str, "url": str}, ...]``. + urls = Column(JSON, default=list) + character_builder_urls = Column(JSON, default=list) is_explicit = Column(Boolean, default=False) is_system_agnostic = Column(Boolean, default=False) + # Special "one-page / small RPG" collection, grouped with system-agnostic + # in the library view. Set by the indexer from the folder name. + is_one_page = Column(Boolean, default=False) created_at = Column(DateTime, default=_utcnow) updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) @@ -54,14 +80,31 @@ class Book(Base): category = Column(String(100), default="core", index=True) description = Column(Text, default="") authors = Column(JSON, default=list) + artists = Column(JSON, default=list) publisher = Column(String(255), default="") + # Legacy single-value URL column, superseded by the ``urls`` list. Kept for + # backward compatibility and as the backfill source. publisher_url = Column(String(512), default="") + # Labeled link list: ``[{"label": str, "url": str}, ...]``. + urls = Column(JSON, default=list) + # Genres (issue #202). Independent of the system's genres; a book may carry + # its own (e.g. a grimdark D&D book tagged Horror as well as Fantasy). + genres = Column(JSON, default=list) + isbn = Column(String(20), default="") + version = Column(String(50), default="") + language = Column(String(20), default="") + # Per-book license override. Empty means "inherit the system's license" — an + # OGL SRD can sit inside an otherwise-proprietary system (issue: metadata). + license = Column(String(100), default="") + # Publication date with variable precision. ``year`` may stand alone; + # ``month`` and/or ``day`` refine it. All nullable. year = Column(Integer, nullable=True) + month = Column(Integer, nullable=True) + day = Column(Integer, nullable=True) file_size = Column(Integer, default=0) page_count = Column(Integer, default=0) mime_type = Column(String(100), default="application/pdf") has_thumbnail = Column(Boolean, default=False) - tags = Column(JSON, default=list) is_explicit = Column(Boolean, default=False) indexed = Column(Boolean, default=False) index_failed = Column(Boolean, default=False) @@ -99,3 +142,84 @@ class BookFolder(Base): id = Column(String(36), primary_key=True, default=_uuid) path = Column(String(1000), nullable=False, unique=True) tags = Column(JSON, default=list) + + +class Genre(Base): + """A curated genre value, optionally nested under a parent (tiered). + + Powers the tiered genre picker (e.g. Science Fiction → Cyberpunk). Defaults + are seeded on migration; users may add their own via settings. ``is_default`` + marks a seeded row so the UI can distinguish it, but defaults are removable. + """ + + __tablename__ = "genres" + + id = Column(String(36), primary_key=True, default=_uuid) + name = Column(String(120), unique=True, nullable=False) + parent_id = Column(String(36), ForeignKey("genres.id"), nullable=True, index=True) + is_default = Column(Boolean, default=False) + sort_order = Column(Integer, default=0) + + parent = relationship("Genre", remote_side=[id], back_populates="children") + children = relationship( + "Genre", back_populates="parent", cascade="all, delete-orphan" + ) + + +class SystemFamily(Base): + """A curated system-family / engine value (e.g. Powered by the Apocalypse).""" + + __tablename__ = "system_families" + + id = Column(String(36), primary_key=True, default=_uuid) + name = Column(String(150), unique=True, nullable=False) + is_default = Column(Boolean, default=False) + sort_order = Column(Integer, default=0) + + +class ParentSystem(Base): + """A curated parent-system value (e.g. "Dungeons & Dragons"). + + The mid tier between a broad system_family ("d20 System") and a concrete + GameSystem ("D&D 5e"). Users manage the list in settings; systems reference + it by name via ``GameSystem.parent_system``. + """ + + __tablename__ = "parent_systems" + + id = Column(String(36), primary_key=True, default=_uuid) + name = Column(String(150), unique=True, nullable=False) + is_default = Column(Boolean, default=False) + sort_order = Column(Integer, default=0) + + +class License(Base): + """A curated license value (e.g. OGL 1.0a, ORC, CC-BY 4.0, Proprietary). + + Applied at the system level as a default and optionally overridden per book. + Seeded with common TTRPG licenses; users may add their own. + """ + + __tablename__ = "licenses" + + id = Column(String(36), primary_key=True, default=_uuid) + name = Column(String(100), unique=True, nullable=False) + is_default = Column(Boolean, default=False) + sort_order = Column(Integer, default=0) + + +class DiceMaterial(Base): + """A curated dice / materials value (e.g. D20, Playing Cards, Tarot Cards). + + Backs the dice/materials picker on systems. Seeded from the built-in default + groups; users may add their own via settings. + """ + + __tablename__ = "dice_materials" + + id = Column(String(36), primary_key=True, default=_uuid) + name = Column(String(120), unique=True, nullable=False) + # Grouping label for the picker ("Dice", "Cards", "Other", "Custom"). + group = Column(String(60), default="Custom") + is_default = Column(Boolean, default=False) + sort_order = Column(Integer, default=0) diff --git a/backend/models/lookup_defaults.py b/backend/models/lookup_defaults.py new file mode 100644 index 0000000..2fc632d --- /dev/null +++ b/backend/models/lookup_defaults.py @@ -0,0 +1,117 @@ +"""Default seed data for the genre and system-family lookup tables (issue #202). + +Kept as plain Python data so both the Alembic migration and any future reseed +routine share one source of truth. The genre tree is loosely modeled on +DriveThruRPG's genre/subgenre taxonomy; families cover the most common design +lineages. All values are user-removable after seeding. +""" +from typing import Sequence + +# Tiered genre defaults. Each entry is ``(name, [children])`` where a child may +# itself be an ``(name, [grandchildren])`` tuple, allowing arbitrary nesting. +GenreNode = tuple[str, Sequence["GenreNode"]] + +DEFAULT_GENRES: Sequence[GenreNode] = ( + ( + "Fantasy", + ( + ("High Fantasy", ()), + ("Dark Fantasy", ()), + ("Grimdark", ()), + ("Sword & Sorcery", ()), + ("Fairy Tale", ()), + ), + ), + ( + "Science Fiction", + ( + ("Cyberpunk", ()), + ("Space Opera", ()), + ("Post-Apocalyptic", ()), + ("Hard SF", ()), + ("Mecha", ()), + ), + ), + ( + "Horror", + ( + ("Cosmic Horror", ()), + ("Survival Horror", ()), + ("Gothic", ()), + ), + ), + ( + "Historical", + ( + ("Ancient", ()), + ("Medieval", ()), + ("Renaissance", ()), + ("Modern", ()), + ), + ), + ("Mystery", ()), + ("Western", ()), + ("Superhero", ()), + ("Steampunk", ()), + ("Modern / Contemporary", ()), + ("Comedy", ()), + ("Adventure", ()), + ("Slice of Life", ()), +) + +# System-family / engine defaults. +DEFAULT_SYSTEM_FAMILIES: Sequence[str] = ( + "Powered by the Apocalypse", + "Forged in the Dark", + "d20 System", + "OSR", + "Year Zero Engine", + "Fate", + "Cypher System", + "GUMSHOE", + "Savage Worlds", + "Basic Roleplaying (BRP)", + "Storyteller / Storytelling", + "GURPS", +) + +# Parent-system defaults are intentionally empty — these are highly library +# specific (users curate their own "Dungeons & Dragons", "Cyberpunk", etc.). +DEFAULT_PARENT_SYSTEMS: Sequence[str] = () + +# Common TTRPG license defaults. User-removable / extendable. +DEFAULT_LICENSES: Sequence[str] = ( + "Proprietary / All Rights Reserved", + "OGL 1.0a", + "ORC License", + "Creative Commons BY 4.0", + "Creative Commons BY-SA 4.0", + "Creative Commons BY-NC 4.0", + "Creative Commons CC0", + "GPL", + "Public Domain", + "Custom / Other", +) + +# Dice / materials defaults, grouped for the picker. Mirrors the front-end +# DICE_MATERIAL_GROUPS so a fresh DB seeds the same starting options. +DiceMaterialDefault = tuple[str, str] # (group, name) + +DEFAULT_DICE_MATERIALS: Sequence[DiceMaterialDefault] = ( + ("Dice", "D4"), + ("Dice", "D6"), + ("Dice", "D8"), + ("Dice", "D10"), + ("Dice", "D12"), + ("Dice", "D20"), + ("Dice", "D100"), + ("Dice", "Custom (System specific)"), + ("Cards", "Playing Cards"), + ("Cards", "Tarot Cards"), + ("Cards", "Custom Deck"), + ("Other", "Tumbling Tower (Jenga Tower)"), + ("Other", "Candles"), + ("Other", "Poker Chips"), + ("Other", "Timers"), + ("Other", "Phone"), +) diff --git a/backend/models/media.py b/backend/models/media.py index 988350e..7c0eee4 100644 --- a/backend/models/media.py +++ b/backend/models/media.py @@ -14,7 +14,6 @@ class GenericMap(Base): filepath = Column(String(1000), nullable=False, unique=True) relative_path = Column(String(1000), nullable=False) description = Column(Text, default="") - tags = Column(JSON, default=list) map_type = Column(String(100), default="") grid_size = Column(String(50), default="") file_size = Column(Integer, default=0) @@ -43,7 +42,6 @@ class Token(Base): filepath = Column(String(1000), nullable=False, unique=True) relative_path = Column(String(1000), nullable=False) description = Column(Text, default="") - tags = Column(JSON, default=list) is_explicit = Column(Boolean, default=False) file_size = Column(Integer, default=0) has_thumbnail = Column(Boolean, default=False) @@ -71,7 +69,6 @@ class Audio(Base): filepath = Column(String(1000), nullable=False, unique=True) relative_path = Column(String(1000), nullable=False) description = Column(Text, default="") - tags = Column(JSON, default=list) # Embedded metadata (best-effort; populated by the indexer via mutagen). duration = Column(Float, default=0.0) # seconds title = Column(String(500), default="") diff --git a/backend/models/tags.py b/backend/models/tags.py new file mode 100644 index 0000000..2e8ea0c --- /dev/null +++ b/backend/models/tags.py @@ -0,0 +1,57 @@ +"""Shared cross-resource tags (issue #235). + +Tags are application-wide: a single tag can be attached to a system, book, map, +token, or audio track. Each :class:`Tag` has an immutable lowercased ``internal`` +key used for matching/deduplication and a human-facing ``display`` value (the +casing the user first entered, editable on the tags page). :class:`ResourceTag` +is the polymorphic join between a tag and a tagged resource. +""" +from sqlalchemy import Column, DateTime, ForeignKey, Index, String, UniqueConstraint + +from .base import Base, _utcnow, _uuid + +# Resource kinds a tag can be attached to. Kept in sync with the tag service and +# the /api/tags router; mirrors the favorites VALID_TYPES set. +RESOURCE_TYPES = frozenset({"system", "book", "map", "token", "audio"}) + +# A tag belongs to exactly one category: the resource type it was created in, or +# ``shared`` once it is used across more than one type (see tag_service). +SHARED_CATEGORY = "shared" +TAG_CATEGORIES = frozenset(RESOURCE_TYPES | {SHARED_CATEGORY}) + + +class Tag(Base): + """An application-wide tag: an internal match key plus a display label.""" + + __tablename__ = "tags" + + id = Column(String(36), primary_key=True, default=_uuid) + # Lowercased, stripped match key — unique and immutable once created. + internal = Column(String(200), nullable=False, unique=True) + # Human-facing label; defaults to the first casing entered, editable later. + display = Column(String(200), nullable=False) + # The tag's category: a single resource type, or ``shared`` when used across + # more than one. Set on creation to the first category it's used in. + category = Column(String(20), nullable=False, default=SHARED_CATEGORY) + created_at = Column(DateTime, default=_utcnow) + + +class ResourceTag(Base): + """Polymorphic link between a :class:`Tag` and a tagged resource row.""" + + __tablename__ = "resource_tags" + + id = Column(String(36), primary_key=True, default=_uuid) + tag_id = Column(String(36), ForeignKey("tags.id", ondelete="CASCADE"), nullable=False) + resource_type = Column(String(20), nullable=False) # system | book | map | token | audio + resource_id = Column(String(36), nullable=False) + created_at = Column(DateTime, default=_utcnow) + + __table_args__ = ( + # A tag applies to a given resource at most once. + UniqueConstraint("tag_id", "resource_type", "resource_id", name="uq_resource_tag"), + # Fast lookup of "all tags for this resource". + Index("ix_resource_tags_resource", "resource_type", "resource_id"), + # Fast lookup of "all resources for this tag". + Index("ix_resource_tags_tag", "tag_id"), + ) diff --git a/backend/models/users.py b/backend/models/users.py index 1e9dc12..a0afeee 100644 --- a/backend/models/users.py +++ b/backend/models/users.py @@ -6,6 +6,7 @@ ForeignKey, Index, Integer, + JSON, String, Text, UniqueConstraint, @@ -68,3 +69,26 @@ class Favorite(Base): created_at = Column(DateTime, default=_utcnow) __table_args__ = (UniqueConstraint("user_id", "item_type", "item_id"),) + + +class SavedFilter(Base): + """A named, per-user saved sort/filter preset for a library scope. + + ``scope`` is one of the browsable content areas (systems/books/maps/tokens/ + audio). ``state`` holds the serialized sort/filter object the UI applies. + At most one filter per (user, scope) may have ``is_default`` set — it is the + view the user lands on. The (user, scope, name) uniqueness prevents dupes. + """ + + __tablename__ = "saved_filters" + + id = Column(String(36), primary_key=True, default=_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) + scope = Column(String(20), nullable=False) + name = Column(String(120), nullable=False) + state = Column(JSON, default=dict) + is_default = Column(Boolean, default=False) + created_at = Column(DateTime, default=_utcnow) + updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) + + __table_args__ = (UniqueConstraint("user_id", "scope", "name"),) diff --git a/backend/routers/audio/_schemas.py b/backend/routers/audio/_schemas.py index 134a6e9..2de57a6 100644 --- a/backend/routers/audio/_schemas.py +++ b/backend/routers/audio/_schemas.py @@ -2,16 +2,7 @@ from typing import Optional from pydantic import BaseModel, field_validator - -def _normalize_tags(tags: list[str]) -> list[str]: - seen = set() - result = [] - for t in tags: - lowered = t.strip().lower() - if lowered and lowered not in seen: - seen.add(lowered) - result.append(lowered) - return result +from ...services import tag_service class AudioUpdate(BaseModel): @@ -20,8 +11,8 @@ class AudioUpdate(BaseModel): @field_validator("tags", mode="before") @classmethod - def lowercase_tags(cls, v): - return _normalize_tags(v) if v is not None else v + def dedupe_tags(cls, v): + return tag_service.dedupe_tags(v) if v is not None else v class FolderTagsUpdate(BaseModel): @@ -30,5 +21,7 @@ class FolderTagsUpdate(BaseModel): @field_validator("tags", mode="before") @classmethod - def lowercase_tags(cls, v): - return _normalize_tags(v) + def dedupe_tags(cls, v): + # Keep the entered casing (dedupe by key); the folder-update handler + # registers catalog rows with this casing and stores internal keys. + return tag_service.dedupe_tags(v) diff --git a/backend/routers/audio/core.py b/backend/routers/audio/core.py index e9cb31a..75d0cc1 100644 --- a/backend/routers/audio/core.py +++ b/backend/routers/audio/core.py @@ -8,6 +8,7 @@ from ...config import get_db from ...models import Audio, AudioFolder +from ...services import tag_service from ...auth import require_gm_or_admin, get_current_user, CurrentUser from ...indexer import _extract_embedded_art, _find_folder_artwork from .._media_access import assert_media_access @@ -25,13 +26,13 @@ } -def _serialize(a: Audio) -> dict: +def _serialize(a: Audio, tags: list[str] | None = None) -> dict: return { "id": a.id, "filename": a.filename, "relative_path": a.relative_path, "description": a.description, - "tags": a.tags or [], + "tags": tags if tags is not None else [], "duration": a.duration or 0.0, "title": a.title or "", "artist": a.artist or "", @@ -50,12 +51,21 @@ def list_audio( q = db.query(Audio) total = q.count() tracks = q.order_by(Audio.filename).offset(offset).limit(limit).all() - return {"total": total, "audio": [_serialize(a) for a in tracks]} + audio_tags = tag_service.display_tags_for_resources(db, "audio", [a.id for a in tracks]) + return { + "total": total, + "audio": [_serialize(a, tags=audio_tags.get(a.id, [])) for a in tracks], + } def list_audio_folders(db: Session = Depends(get_db)): folders = db.query(AudioFolder).all() - return {"folders": [{"path": f.path, "tags": f.tags or []} for f in folders]} + return { + "folders": [ + {"path": f.path, "tags": tag_service.folder_display_tags(db, f.tags or [])} + for f in folders + ] + } def update_audio_folder( @@ -63,13 +73,11 @@ def update_audio_folder( _: CurrentUser = Depends(require_gm_or_admin), db: Session = Depends(get_db), ): - folder = db.query(AudioFolder).filter_by(path=data.path).first() - if folder: - folder.tags = data.tags - else: - db.add(AudioFolder(path=data.path, tags=data.tags)) + internals = tag_service.upsert_folder_tags( + db, AudioFolder, data.path, data.tags, category="audio" + ) db.commit() - return {"path": data.path, "tags": data.tags} + return {"path": data.path, "tags": internals} def get_audio( @@ -84,9 +92,9 @@ def get_audio( folder_path = "/".join(Path(a.relative_path).parts[1:-1]) folder = db.query(AudioFolder).filter_by(path=folder_path).first() return { - **_serialize(a), + **_serialize(a, tags=tag_service.display_tags_for_resource(db, "audio", a.id)), "folder_path": folder_path, - "folder_tags": folder.tags if folder else [], + "folder_tags": tag_service.folder_display_tags(db, folder.tags if folder else []), } @@ -140,7 +148,10 @@ def update_audio( a = db.query(Audio).filter_by(id=audio_id).first() if not a: raise HTTPException(404) - for field, value in data.model_dump(exclude_none=True).items(): + payload = data.model_dump(exclude_none=True) + tag_service.sync_tags_from_payload(db, "audio", a.id, payload) + payload.pop("tags", None) # tags live in the shared-tag tables, not a column + for field, value in payload.items(): setattr(a, field, value) db.commit() return {"status": "ok"} diff --git a/backend/routers/books/_schemas.py b/backend/routers/books/_schemas.py index 16f67e4..88de15a 100644 --- a/backend/routers/books/_schemas.py +++ b/backend/routers/books/_schemas.py @@ -1,6 +1,14 @@ """Pydantic schemas for the books API.""" from typing import Optional -from pydantic import BaseModel + +from pydantic import BaseModel, field_validator + + +class LinkEntry(BaseModel): + """A labeled link on a book (publisher / DriveThruRPG page, etc.).""" + + label: str = "" + url: str = "" class BookUpdate(BaseModel): @@ -8,8 +16,47 @@ class BookUpdate(BaseModel): category: Optional[str] = None description: Optional[str] = None authors: Optional[list[str]] = None + artists: Optional[list[str]] = None + genres: Optional[list[str]] = None publisher: Optional[str] = None + # Legacy single-value URL; still accepted. New clients send ``urls``. publisher_url: Optional[str] = None + urls: Optional[list[LinkEntry]] = None + isbn: Optional[str] = None + version: Optional[str] = None + language: Optional[str] = None + license: Optional[str] = None year: Optional[int] = None + month: Optional[int] = None + day: Optional[int] = None tags: Optional[list[str]] = None is_explicit: Optional[bool] = None + + @field_validator("genres", mode="before") + @classmethod + def strip_genres(cls, v): + if v is None: + return v + seen: set[str] = set() + out: list[str] = [] + for item in v: + s = str(item).strip() + key = s.lower() + if s and key not in seen: + seen.add(key) + out.append(s) + return out + + @field_validator("month") + @classmethod + def check_month(cls, v): + if v is not None and not (1 <= v <= 12): + raise ValueError("month must be between 1 and 12") + return v + + @field_validator("day") + @classmethod + def check_day(cls, v): + if v is not None and not (1 <= v <= 31): + raise ValueError("day must be between 1 and 31") + return v diff --git a/backend/routers/books/core.py b/backend/routers/books/core.py index aa985e1..98bccc7 100644 --- a/backend/routers/books/core.py +++ b/backend/routers/books/core.py @@ -15,6 +15,7 @@ from ...indexer import slugify from ...models import Book, GameSystem +from ...services import tag_service from ._helpers import _allow_explicit, _assert_book_access, _invalidate_book_cache from ._schemas import BookUpdate @@ -89,9 +90,19 @@ def get_book( "page_count": book.page_count, "file_size": book.file_size, "authors": book.authors or [], + "artists": book.artists or [], + "genres": book.genres or [], "publisher": book.publisher, "publisher_url": book.publisher_url, + "urls": book.urls or [], + "isbn": book.isbn or "", + "version": book.version or "", + "language": book.language or "", + "license": book.license or "", "year": book.year, + "month": book.month, + "day": book.day, + "tags": tag_service.display_tags_for_resource(db, "book", book.id), "indexed": book.indexed, "index_failed": book.index_failed, "ocr_indexed": book.index_error == "ocr", @@ -116,7 +127,12 @@ def update_book( book = db.query(Book).filter_by(id=book_id).first() if not book: raise HTTPException(404, "Book not found") - for field, value in data.model_dump(exclude_none=True).items(): + payload = data.model_dump(exclude_none=True) + # Tags live in the shared-tag tables, not a column on the book (issue #235), + # so apply them via the service and keep them out of the setattr loop. + tag_service.sync_tags_from_payload(db, "book", book.id, payload) + payload.pop("tags", None) + for field, value in payload.items(): setattr(book, field, value) db.commit() return {"status": "ok"} diff --git a/backend/routers/downloads/_helpers.py b/backend/routers/downloads/_helpers.py index 03f0f3c..4bb2131 100644 --- a/backend/routers/downloads/_helpers.py +++ b/backend/routers/downloads/_helpers.py @@ -183,9 +183,12 @@ def _files_for_system_category( def _files_for_book_folder( db, system_id: str, folder: str, see_explicit: bool ) -> tuple[list, str]: - """Books in a named subfolder within any category. - Path structure: books/{SystemName}/{categoryDir}/{folder}/... - The folder name is matched against path segment index 3 (0-based). + """Books in a nested subfolder within any category. + Path structure: books/{SystemName}/{categoryDir}/{folder...}/... + ``folder`` is the '/'-joined path of segments after the category dir (index 3 + onward), so it matches arbitrarily deep nesting (e.g. "monsters/spelljammer"). + Books deeper than the requested folder are included; their archive paths keep + the sub-hierarchy below ``folder`` so nesting is preserved on extraction. """ system = db.query(GameSystem).filter_by(id=system_id).first() if not system: @@ -195,14 +198,21 @@ def _files_for_book_folder( if not see_explicit: q = q.filter(Book.is_explicit != True) - def _in_folder(b: Book) -> bool: + target = [seg for seg in folder.replace("\\", "/").split("/") if seg] + + def _subpath(b: Book) -> Optional[list[str]]: + """Segments after the category dir (parts[3:]), or None when the book + is not under ``folder``. Returns the arcname parts on a match.""" parts = b.relative_path.replace("\\", "/").split("/") - return len(parts) > 4 and parts[3] == folder + sub = parts[3:] + if len(sub) <= len(target) or sub[: len(target)] != target: + return None + return sub[len(target):] files = [ - (safe, _safe_arcname(b.filename)) + (safe, _safe_arcname("/".join(rel))) for b in q.all() - if _in_folder(b) and (safe := _safe_filepath(b.filepath)) + if (rel := _subpath(b)) is not None and (safe := _safe_filepath(b.filepath)) ] return files, f"{_safe_name(system.name)}_{_safe_name(folder)}" diff --git a/backend/routers/export/core.py b/backend/routers/export/core.py index 1c6e45f..85c089b 100644 --- a/backend/routers/export/core.py +++ b/backend/routers/export/core.py @@ -19,6 +19,7 @@ Token, TokenFolder, ) +from ...services import tag_service def export_tags( @@ -35,21 +36,27 @@ def export_tags( payload: dict = {"exported_at": exported_at} if include_library: + system_rows = db.query(GameSystem).order_by(GameSystem.name).all() + system_tags = tag_service.display_tags_for_resources( + db, "system", [s.id for s in system_rows] + ) systems = [ - {"slug": s.slug, "name": s.name, "tags": s.tags or []} - for s in db.query(GameSystem).order_by(GameSystem.name).all() + {"slug": s.slug, "name": s.name, "tags": system_tags.get(s.id, [])} + for s in system_rows ] + book_rows = db.query(Book).order_by(Book.title).all() + book_tags = tag_service.display_tags_for_resources(db, "book", [b.id for b in book_rows]) books = [ { "id": b.id, "title": b.title, "filepath": b.relative_path, - "tags": b.tags or [], + "tags": book_tags.get(b.id, []), } - for b in db.query(Book).order_by(Book.title).all() + for b in book_rows ] book_folders = [ - {"path": f.path, "tags": f.tags or []} + {"path": f.path, "tags": tag_service.folder_display_tags(db, f.tags or [])} for f in db.query(BookFolder).order_by(BookFolder.path).all() ] payload["library"] = { @@ -59,49 +66,59 @@ def export_tags( } if include_maps: + map_rows = db.query(GenericMap).order_by(GenericMap.filename).all() + map_tags = tag_service.display_tags_for_resources(db, "map", [m.id for m in map_rows]) maps = [ { "id": m.id, "name": m.filename, "folder": "/".join(m.relative_path.split("/")[:-1]), - "tags": m.tags or [], + "tags": map_tags.get(m.id, []), } - for m in db.query(GenericMap).order_by(GenericMap.filename).all() + for m in map_rows ] map_folders = [ - {"path": f.path, "tags": f.tags or []} + {"path": f.path, "tags": tag_service.folder_display_tags(db, f.tags or [])} for f in db.query(MapFolder).order_by(MapFolder.path).all() ] payload["maps"] = {"items": maps, "folders": map_folders} if include_tokens: + token_rows = db.query(Token).order_by(Token.filename).all() + token_tags = tag_service.display_tags_for_resources( + db, "token", [t.id for t in token_rows] + ) tokens = [ { "id": t.id, "name": t.filename, "folder": "/".join(t.relative_path.split("/")[:-1]), - "tags": t.tags or [], + "tags": token_tags.get(t.id, []), } - for t in db.query(Token).order_by(Token.filename).all() + for t in token_rows ] token_folders = [ - {"path": f.path, "tags": f.tags or []} + {"path": f.path, "tags": tag_service.folder_display_tags(db, f.tags or [])} for f in db.query(TokenFolder).order_by(TokenFolder.path).all() ] payload["tokens"] = {"items": tokens, "folders": token_folders} if include_audio: + audio_rows = db.query(Audio).order_by(Audio.filename).all() + audio_tags = tag_service.display_tags_for_resources( + db, "audio", [a.id for a in audio_rows] + ) audio = [ { "id": a.id, "name": a.filename, "folder": "/".join(a.relative_path.split("/")[:-1]), - "tags": a.tags or [], + "tags": audio_tags.get(a.id, []), } - for a in db.query(Audio).order_by(Audio.filename).all() + for a in audio_rows ] audio_folders = [ - {"path": f.path, "tags": f.tags or []} + {"path": f.path, "tags": tag_service.folder_display_tags(db, f.tags or [])} for f in db.query(AudioFolder).order_by(AudioFolder.path).all() ] payload["audio"] = {"items": audio, "folders": audio_folders} diff --git a/backend/routers/favorites/core.py b/backend/routers/favorites/core.py index ca04f15..8e2f338 100644 --- a/backend/routers/favorites/core.py +++ b/backend/routers/favorites/core.py @@ -6,12 +6,14 @@ from ...config import get_db from ...auth import get_current_user, CurrentUser from ...models import Favorite, Book, GenericMap, Token, Audio, GameSystem +from ...services import tag_service from ..systems._helpers import resolve_cover_book_id from ._schemas import FavoriteIn router = APIRouter() -VALID_TYPES = {"book", "map", "token", "audio", "system"} +# A "tag" favorite is keyed by the tag's internal string (not a row id). +VALID_TYPES = {"book", "map", "token", "audio", "system", "tag"} def list_favorites(user: CurrentUser = Depends(get_current_user), db: Session = Depends(get_db)): @@ -22,12 +24,19 @@ def list_favorites(user: CurrentUser = Depends(get_current_user), db: Session = token_ids = [r.item_id for r in rows if r.item_type == "token"] audio_ids = [r.item_id for r in rows if r.item_type == "audio"] system_ids = [r.item_id for r in rows if r.item_type == "system"] + tag_internals = [r.item_id for r in rows if r.item_type == "tag"] books = {b.id: b for b in db.query(Book).filter(Book.id.in_(book_ids))} maps = {m.id: m for m in db.query(GenericMap).filter(GenericMap.id.in_(map_ids))} tokens = {t.id: t for t in db.query(Token).filter(Token.id.in_(token_ids))} audio = {a.id: a for a in db.query(Audio).filter(Audio.id.in_(audio_ids))} systems = {s.id: s for s in db.query(GameSystem).filter(GameSystem.id.in_(system_ids))} + tag_meta = tag_service.tags_meta_for_internals(db, tag_internals) + + # Shared tags (display strings) for the media types that surface them. + map_tags = tag_service.display_tags_for_resources(db, "map", map_ids) + token_tags = tag_service.display_tags_for_resources(db, "token", token_ids) + audio_tags = tag_service.display_tags_for_resources(db, "audio", audio_ids) enriched = [] for r in rows: @@ -54,7 +63,7 @@ def list_favorites(user: CurrentUser = Depends(get_current_user), db: Session = "filename": m.filename, "has_thumbnail": m.has_thumbnail, "file_size": m.file_size, - "tags": m.tags or [], + "tags": map_tags.get(m.id, []), } ) elif r.item_type == "token" and r.item_id in tokens: @@ -66,7 +75,7 @@ def list_favorites(user: CurrentUser = Depends(get_current_user), db: Session = "filename": t.filename, "has_thumbnail": t.has_thumbnail, "file_size": t.file_size, - "tags": t.tags or [], + "tags": token_tags.get(t.id, []), } ) elif r.item_type == "audio" and r.item_id in audio: @@ -80,7 +89,7 @@ def list_favorites(user: CurrentUser = Depends(get_current_user), db: Session = "duration": a.duration or 0.0, "has_artwork": bool(a.has_artwork), "file_size": a.file_size, - "tags": a.tags or [], + "tags": audio_tags.get(a.id, []), } ) elif r.item_type == "system" and r.item_id in systems: @@ -94,6 +103,17 @@ def list_favorites(user: CurrentUser = Depends(get_current_user), db: Session = "cover_book_id": resolve_cover_book_id(db, s), } ) + elif r.item_type == "tag" and r.item_id in tag_meta: + meta = tag_meta[r.item_id] + enriched.append( + { + "item_type": "tag", + "item_id": r.item_id, + "internal": meta["internal"], + "display": meta["display"], + "count": meta["count"], + } + ) all_ids = [{"item_type": r.item_type, "item_id": r.item_id} for r in rows] return {"favorites": all_ids, "items": enriched} diff --git a/backend/routers/library/core.py b/backend/routers/library/core.py index a586919..f8f7bf7 100644 --- a/backend/routers/library/core.py +++ b/backend/routers/library/core.py @@ -1,12 +1,20 @@ """Library scan-status, rescan, and stats endpoints.""" import sys +import time from typing import Optional +import httpx from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Header, Request from sqlalchemy.orm import Session from sqlalchemy import func -from ...config import LIBRARY_PATH, VERSION, COMMIT_HASH, get_db +from ...config import ( + LIBRARY_PATH, + VERSION, + COMMIT_HASH, + DISABLE_VERSION_CHECKING, + get_db, +) from ...models import GameSystem, Book, GenericMap, Token, Audio from ...auth import require_admin, optional_get_current_user, get_current_user, CurrentUser from ...indexer import resolve_scope @@ -89,6 +97,7 @@ def get_stats( return { "game_systems": db.query(GameSystem) .filter(GameSystem.is_system_agnostic != True) # noqa: E712 + .filter(GameSystem.is_one_page != True) # noqa: E712 .count(), "books": db.query(Book).count(), "maps": db.query(GenericMap).count(), @@ -115,3 +124,54 @@ def get_about(_: CurrentUser = Depends(get_current_user)): "commit_hash": COMMIT_HASH, "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", } + + +GITHUB_REPO = "hunter-read/grimoire" +_LATEST_RELEASE_TTL = 3600 # seconds +# (fetched_at, latest_version) — module-level cache so we proxy GitHub at most +# once an hour regardless of how many clients poll. +_latest_release_cache: tuple[float, Optional[str]] = (0.0, None) + + +def _fetch_latest_release() -> Optional[str]: + """Return the newest release tag (without a leading ``v``), or ``None``. + + Proxies GitHub's releases API server-side so the browser makes only a + same-origin request — third-party request blockers can't break the update + check. Any failure (network, rate limit, no releases) yields ``None``. + """ + if DISABLE_VERSION_CHECKING: + return None + global _latest_release_cache + now = time.monotonic() + fetched_at, cached = _latest_release_cache + if cached is not None and now - fetched_at < _LATEST_RELEASE_TTL: + return cached + latest: Optional[str] = None + try: + resp = httpx.get( + f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest", + headers={"Accept": "application/vnd.github+json"}, + timeout=5.0, + ) + if resp.status_code == 200: + tag = resp.json().get("tag_name") + if isinstance(tag, str) and tag: + latest = tag.lstrip("v") + except httpx.HTTPError: + latest = None + _latest_release_cache = (now, latest) + return latest + + +@router.get( + "/latest-release", + summary="Latest published release", + description=( + "Returns the latest GitHub release version for the update-available check. " + "Proxied server-side (and cached) so the browser makes a same-origin request " + "that privacy browsers and request blockers won't block. Login required." + ), +) +def get_latest_release(_: CurrentUser = Depends(get_current_user)): + return {"latest_version": _fetch_latest_release()} diff --git a/backend/routers/lookups/__init__.py b/backend/routers/lookups/__init__.py new file mode 100644 index 0000000..0dec5d2 --- /dev/null +++ b/backend/routers/lookups/__init__.py @@ -0,0 +1,113 @@ +"""Lookups package — genre and system-family reference values (issue #202). + +Registers CRUD routes for the curated genre tree and system-family list that +feed the editor dropdowns and the settings management screens. +""" +from fastapi import APIRouter + +from .core import ( + create_dice_material, + create_genre, + create_license, + create_parent_system, + create_system_family, + delete_dice_material, + delete_genre, + delete_license, + delete_parent_system, + delete_system_family, + list_dice_materials, + list_genres, + list_licenses, + list_parent_systems, + list_system_families, +) + +router = APIRouter(tags=["lookups"]) + +__all__ = ["router"] + +router.add_api_route( + "/genres", list_genres, methods=["GET"], summary="List all genres (tiered)" +) +router.add_api_route( + "/genres", create_genre, methods=["POST"], summary="Create a custom genre (admin)" +) +router.add_api_route( + "/genres/{genre_id}", + delete_genre, + methods=["DELETE"], + summary="Delete a genre (admin; blocked if in use unless force=true)", +) +router.add_api_route( + "/system-families", + list_system_families, + methods=["GET"], + summary="List all system families", +) +router.add_api_route( + "/system-families", + create_system_family, + methods=["POST"], + summary="Create a custom system family (admin)", +) +router.add_api_route( + "/system-families/{family_id}", + delete_system_family, + methods=["DELETE"], + summary="Delete a system family (admin; blocked if in use unless force=true)", +) +router.add_api_route( + "/parent-systems", + list_parent_systems, + methods=["GET"], + summary="List all parent systems", +) +router.add_api_route( + "/parent-systems", + create_parent_system, + methods=["POST"], + summary="Create a custom parent system (admin)", +) +router.add_api_route( + "/parent-systems/{parent_id}", + delete_parent_system, + methods=["DELETE"], + summary="Delete a parent system (admin; blocked if in use unless force=true)", +) +router.add_api_route( + "/licenses", + list_licenses, + methods=["GET"], + summary="List all licenses", +) +router.add_api_route( + "/licenses", + create_license, + methods=["POST"], + summary="Create a custom license (admin)", +) +router.add_api_route( + "/licenses/{license_id}", + delete_license, + methods=["DELETE"], + summary="Delete a license (admin; blocked if in use unless force=true)", +) +router.add_api_route( + "/dice-materials", + list_dice_materials, + methods=["GET"], + summary="List all dice/materials", +) +router.add_api_route( + "/dice-materials", + create_dice_material, + methods=["POST"], + summary="Create a custom dice/material (admin)", +) +router.add_api_route( + "/dice-materials/{material_id}", + delete_dice_material, + methods=["DELETE"], + summary="Delete a dice/material (admin; blocked if in use unless force=true)", +) diff --git a/backend/routers/lookups/_helpers.py b/backend/routers/lookups/_helpers.py new file mode 100644 index 0000000..0a47ccb --- /dev/null +++ b/backend/routers/lookups/_helpers.py @@ -0,0 +1,127 @@ +"""Shared helpers for the lookups router.""" +from typing import Any + +from sqlalchemy.orm import Session + +from ...models import ( + Book, + DiceMaterial, + GameSystem, + Genre, + License, + ParentSystem, + SystemFamily, +) + + +def serialize_genre(g: Genre) -> dict[str, Any]: + return { + "id": g.id, + "name": g.name, + "parent_id": g.parent_id, + "is_default": bool(g.is_default), + "sort_order": g.sort_order or 0, + } + + +def serialize_family(f: SystemFamily) -> dict[str, Any]: + return { + "id": f.id, + "name": f.name, + "is_default": bool(f.is_default), + "sort_order": f.sort_order or 0, + } + + +def serialize_parent_system(p: ParentSystem) -> dict[str, Any]: + return { + "id": p.id, + "name": p.name, + "is_default": bool(p.is_default), + "sort_order": p.sort_order or 0, + } + + +def serialize_license(lic: License) -> dict[str, Any]: + return { + "id": lic.id, + "name": lic.name, + "is_default": bool(lic.is_default), + "sort_order": lic.sort_order or 0, + } + + +def serialize_dice_material(d: DiceMaterial) -> dict[str, Any]: + return { + "id": d.id, + "name": d.name, + "group": d.group or "Custom", + "is_default": bool(d.is_default), + "sort_order": d.sort_order or 0, + } + + +def _matches(field: Any, name: str) -> bool: + """Case-insensitive test whether a JSON list / scalar column holds ``name``.""" + if field is None: + return False + wanted = name.strip().lower() + if isinstance(field, list): + return any(str(v).strip().lower() == wanted for v in field) + return str(field).strip().lower() == wanted + + +def count_genre_usage(db: Session, name: str) -> int: + """Count systems + books whose ``genres`` list contains ``name``. + + JSON membership isn't portable in SQLite without json1 filtering, so this + loads the (small) candidate columns and checks in Python. Genre lists are + tiny and the lookup-management screens are admin-only, so the cost is fine. + """ + count = 0 + for (genres,) in db.query(GameSystem.genres).all(): + if _matches(genres, name): + count += 1 + for (genres,) in db.query(Book.genres).all(): + if _matches(genres, name): + count += 1 + return count + + +def count_family_usage(db: Session, name: str) -> int: + """Count systems whose ``system_family`` equals ``name`` (case-insensitive).""" + count = 0 + for (fam,) in db.query(GameSystem.system_family).all(): + if _matches(fam, name): + count += 1 + return count + + +def count_parent_system_usage(db: Session, name: str) -> int: + """Count systems whose ``parent_system`` equals ``name`` (case-insensitive).""" + count = 0 + for (parent,) in db.query(GameSystem.parent_system).all(): + if _matches(parent, name): + count += 1 + return count + + +def count_license_usage(db: Session, name: str) -> int: + """Count systems + books whose ``license`` equals ``name`` (case-insensitive).""" + count = 0 + for (lic,) in db.query(GameSystem.license).all(): + if _matches(lic, name): + count += 1 + for (lic,) in db.query(Book.license).all(): + if _matches(lic, name): + count += 1 + return count + + +def count_dice_material_usage(db: Session, name: str) -> int: + """Count systems whose ``dice_materials`` list contains ``name``.""" + count = 0 + for (materials,) in db.query(GameSystem.dice_materials).all(): + if _matches(materials, name): + count += 1 + return count diff --git a/backend/routers/lookups/_schemas.py b/backend/routers/lookups/_schemas.py new file mode 100644 index 0000000..aabf17b --- /dev/null +++ b/backend/routers/lookups/_schemas.py @@ -0,0 +1,66 @@ +"""Pydantic schemas for the genre / system-family lookup API (issue #202).""" +from typing import Optional + +from pydantic import BaseModel, field_validator + + +class GenreCreate(BaseModel): + name: str + parent_id: Optional[str] = None + + @field_validator("name") + @classmethod + def name_not_blank(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("name must not be blank") + return v + + +class SystemFamilyCreate(BaseModel): + name: str + + @field_validator("name") + @classmethod + def name_not_blank(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("name must not be blank") + return v + + +class ParentSystemCreate(BaseModel): + name: str + + @field_validator("name") + @classmethod + def name_not_blank(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("name must not be blank") + return v + + +class LicenseCreate(BaseModel): + name: str + + @field_validator("name") + @classmethod + def name_not_blank(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("name must not be blank") + return v + + +class DiceMaterialCreate(BaseModel): + name: str + group: Optional[str] = "Custom" + + @field_validator("name") + @classmethod + def name_not_blank(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("name must not be blank") + return v diff --git a/backend/routers/lookups/core.py b/backend/routers/lookups/core.py new file mode 100644 index 0000000..f2c43c3 --- /dev/null +++ b/backend/routers/lookups/core.py @@ -0,0 +1,308 @@ +"""Genre and system-family lookup endpoint handlers (issue #202). + +Reads are available to any authenticated user (they power editor dropdowns); +mutations are admin-only. Deleting a value that is still attached to a system or +book is blocked with 409 unless ``?force=true`` is passed, so the UI can warn +and confirm first. +""" +from fastapi import Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from ...auth import CurrentUser, get_current_user, require_admin +from ...config import get_db +from ...models import DiceMaterial, Genre, License, ParentSystem, SystemFamily +from ._helpers import ( + count_dice_material_usage, + count_family_usage, + count_genre_usage, + count_license_usage, + count_parent_system_usage, + serialize_dice_material, + serialize_family, + serialize_genre, + serialize_license, + serialize_parent_system, +) +from ._schemas import ( + DiceMaterialCreate, + GenreCreate, + LicenseCreate, + ParentSystemCreate, + SystemFamilyCreate, +) + + +# --- Genres ------------------------------------------------------------------- + + +def list_genres( + _: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Return all genres, ordered for a tiered picker (parents then children).""" + genres = ( + db.query(Genre).order_by(Genre.sort_order, Genre.name).all() + ) + return {"genres": [serialize_genre(g) for g in genres]} + + +def create_genre( + data: GenreCreate, + _: CurrentUser = Depends(require_admin), + db: Session = Depends(get_db), +): + name = data.name.strip() + existing = db.query(Genre).filter(Genre.name.ilike(name)).first() + if existing: + raise HTTPException(409, "A genre with that name already exists") + if data.parent_id: + parent = db.query(Genre).filter_by(id=data.parent_id).first() + if not parent: + raise HTTPException(404, "Parent genre not found") + max_order = db.query(Genre).count() + genre = Genre( + name=name, parent_id=data.parent_id, is_default=False, sort_order=max_order + ) + db.add(genre) + db.commit() + return serialize_genre(genre) + + +def delete_genre( + genre_id: str, + force: bool = Query(False), + _: CurrentUser = Depends(require_admin), + db: Session = Depends(get_db), +): + genre = db.query(Genre).filter_by(id=genre_id).first() + if not genre: + raise HTTPException(404, "Genre not found") + usage = count_genre_usage(db, genre.name) + if usage and not force: + raise HTTPException( + 409, + detail={ + "message": "Genre is in use", + "usage_count": usage, + "name": genre.name, + }, + ) + # Children are removed by the cascade on the self-referential relationship. + db.delete(genre) + db.commit() + return {"status": "ok", "removed_usage": usage} + + +# --- System families ---------------------------------------------------------- + + +def list_system_families( + _: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +): + families = ( + db.query(SystemFamily).order_by(SystemFamily.sort_order, SystemFamily.name).all() + ) + return {"families": [serialize_family(f) for f in families]} + + +def create_system_family( + data: SystemFamilyCreate, + _: CurrentUser = Depends(require_admin), + db: Session = Depends(get_db), +): + name = data.name.strip() + existing = db.query(SystemFamily).filter(SystemFamily.name.ilike(name)).first() + if existing: + raise HTTPException(409, "A system family with that name already exists") + max_order = db.query(SystemFamily).count() + family = SystemFamily(name=name, is_default=False, sort_order=max_order) + db.add(family) + db.commit() + return serialize_family(family) + + +def delete_system_family( + family_id: str, + force: bool = Query(False), + _: CurrentUser = Depends(require_admin), + db: Session = Depends(get_db), +): + family = db.query(SystemFamily).filter_by(id=family_id).first() + if not family: + raise HTTPException(404, "System family not found") + usage = count_family_usage(db, family.name) + if usage and not force: + raise HTTPException( + 409, + detail={ + "message": "System family is in use", + "usage_count": usage, + "name": family.name, + }, + ) + db.delete(family) + db.commit() + return {"status": "ok", "removed_usage": usage} + + +# --- Parent systems ----------------------------------------------------------- + + +def list_parent_systems( + _: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +): + parents = ( + db.query(ParentSystem).order_by(ParentSystem.sort_order, ParentSystem.name).all() + ) + return {"parent_systems": [serialize_parent_system(p) for p in parents]} + + +def create_parent_system( + data: ParentSystemCreate, + _: CurrentUser = Depends(require_admin), + db: Session = Depends(get_db), +): + name = data.name.strip() + existing = db.query(ParentSystem).filter(ParentSystem.name.ilike(name)).first() + if existing: + raise HTTPException(409, "A parent system with that name already exists") + max_order = db.query(ParentSystem).count() + parent = ParentSystem(name=name, is_default=False, sort_order=max_order) + db.add(parent) + db.commit() + return serialize_parent_system(parent) + + +def delete_parent_system( + parent_id: str, + force: bool = Query(False), + _: CurrentUser = Depends(require_admin), + db: Session = Depends(get_db), +): + parent = db.query(ParentSystem).filter_by(id=parent_id).first() + if not parent: + raise HTTPException(404, "Parent system not found") + usage = count_parent_system_usage(db, parent.name) + if usage and not force: + raise HTTPException( + 409, + detail={ + "message": "Parent system is in use", + "usage_count": usage, + "name": parent.name, + }, + ) + db.delete(parent) + db.commit() + return {"status": "ok", "removed_usage": usage} + + +# --- Licenses ----------------------------------------------------------------- + + +def list_licenses( + _: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +): + licenses = db.query(License).order_by(License.sort_order, License.name).all() + return {"licenses": [serialize_license(lic) for lic in licenses]} + + +def create_license( + data: LicenseCreate, + _: CurrentUser = Depends(require_admin), + db: Session = Depends(get_db), +): + name = data.name.strip() + existing = db.query(License).filter(License.name.ilike(name)).first() + if existing: + raise HTTPException(409, "A license with that name already exists") + max_order = db.query(License).count() + lic = License(name=name, is_default=False, sort_order=max_order) + db.add(lic) + db.commit() + return serialize_license(lic) + + +def delete_license( + license_id: str, + force: bool = Query(False), + _: CurrentUser = Depends(require_admin), + db: Session = Depends(get_db), +): + lic = db.query(License).filter_by(id=license_id).first() + if not lic: + raise HTTPException(404, "License not found") + usage = count_license_usage(db, lic.name) + if usage and not force: + raise HTTPException( + 409, + detail={ + "message": "License is in use", + "usage_count": usage, + "name": lic.name, + }, + ) + db.delete(lic) + db.commit() + return {"status": "ok", "removed_usage": usage} + + +# --- Dice / materials --------------------------------------------------------- + + +def list_dice_materials( + _: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +): + materials = ( + db.query(DiceMaterial).order_by(DiceMaterial.sort_order, DiceMaterial.name).all() + ) + return {"dice_materials": [serialize_dice_material(d) for d in materials]} + + +def create_dice_material( + data: DiceMaterialCreate, + _: CurrentUser = Depends(require_admin), + db: Session = Depends(get_db), +): + name = data.name.strip() + existing = db.query(DiceMaterial).filter(DiceMaterial.name.ilike(name)).first() + if existing: + raise HTTPException(409, "A dice/material with that name already exists") + max_order = db.query(DiceMaterial).count() + material = DiceMaterial( + name=name, + group=(data.group or "Custom").strip() or "Custom", + is_default=False, + sort_order=max_order, + ) + db.add(material) + db.commit() + return serialize_dice_material(material) + + +def delete_dice_material( + material_id: str, + force: bool = Query(False), + _: CurrentUser = Depends(require_admin), + db: Session = Depends(get_db), +): + material = db.query(DiceMaterial).filter_by(id=material_id).first() + if not material: + raise HTTPException(404, "Dice/material not found") + usage = count_dice_material_usage(db, material.name) + if usage and not force: + raise HTTPException( + 409, + detail={ + "message": "Dice/material is in use", + "usage_count": usage, + "name": material.name, + }, + ) + db.delete(material) + db.commit() + return {"status": "ok", "removed_usage": usage} diff --git a/backend/routers/maps/_schemas.py b/backend/routers/maps/_schemas.py index f887f98..3496811 100644 --- a/backend/routers/maps/_schemas.py +++ b/backend/routers/maps/_schemas.py @@ -2,16 +2,7 @@ from typing import Optional from pydantic import BaseModel, field_validator - -def _normalize_tags(tags: list[str]) -> list[str]: - seen = set() - result = [] - for t in tags: - lowered = t.strip().lower() - if lowered and lowered not in seen: - seen.add(lowered) - result.append(lowered) - return result +from ...services import tag_service class MapUpdate(BaseModel): @@ -22,8 +13,8 @@ class MapUpdate(BaseModel): @field_validator("tags", mode="before") @classmethod - def lowercase_tags(cls, v): - return _normalize_tags(v) if v is not None else v + def dedupe_tags(cls, v): + return tag_service.dedupe_tags(v) if v is not None else v class FolderTagsUpdate(BaseModel): @@ -32,5 +23,7 @@ class FolderTagsUpdate(BaseModel): @field_validator("tags", mode="before") @classmethod - def lowercase_tags(cls, v): - return _normalize_tags(v) + def dedupe_tags(cls, v): + # Keep the entered casing (dedupe by key); the folder-update handler + # registers catalog rows with this casing and stores internal keys. + return tag_service.dedupe_tags(v) diff --git a/backend/routers/maps/core.py b/backend/routers/maps/core.py index b2e0374..4fb0022 100644 --- a/backend/routers/maps/core.py +++ b/backend/routers/maps/core.py @@ -11,6 +11,7 @@ from ...config import _PAGE_CACHE_HEADERS, THUMB_DIR, get_db from ...models import GenericMap, MapFolder +from ...services import tag_service from ...auth import require_gm_or_admin, get_current_user, CurrentUser from ...indexer import slugify from .._media_access import assert_media_access @@ -45,6 +46,7 @@ def list_maps( else: total = q.count() maps = q.offset(offset).limit(limit).all() + map_tags = tag_service.display_tags_for_resources(db, "map", [m.id for m in maps]) return { "total": total, "maps": [ @@ -53,7 +55,7 @@ def list_maps( "filename": m.filename, "relative_path": m.relative_path, "description": m.description, - "tags": m.tags or [], + "tags": map_tags.get(m.id, []), "map_type": m.map_type, "file_size": m.file_size, "has_thumbnail": m.has_thumbnail, @@ -66,7 +68,12 @@ def list_maps( def list_map_folders(db: Session = Depends(get_db)): folders = db.query(MapFolder).all() - return {"folders": [{"path": f.path, "tags": f.tags or []} for f in folders]} + return { + "folders": [ + {"path": f.path, "tags": tag_service.folder_display_tags(db, f.tags or [])} + for f in folders + ] + } def update_map_folder( @@ -74,13 +81,11 @@ def update_map_folder( _: CurrentUser = Depends(require_gm_or_admin), db: Session = Depends(get_db), ): - folder = db.query(MapFolder).filter_by(path=data.path).first() - if folder: - folder.tags = data.tags - else: - db.add(MapFolder(path=data.path, tags=data.tags)) + # Register catalog rows (display casing lives there) and store internal keys + # on the folder, so a tags.json rescan can't revert user edits. + internals = tag_service.upsert_folder_tags(db, MapFolder, data.path, data.tags, category="map") db.commit() - return {"path": data.path, "tags": data.tags} + return {"path": data.path, "tags": internals} def get_map( @@ -100,9 +105,9 @@ def get_map( "filename": m.filename, "relative_path": m.relative_path, "folder_path": folder_path, - "folder_tags": folder.tags if folder else [], + "folder_tags": tag_service.folder_display_tags(db, folder.tags if folder else []), "description": m.description, - "tags": m.tags or [], + "tags": tag_service.display_tags_for_resource(db, "map", m.id), "map_type": m.map_type, "grid_size": m.grid_size, "file_size": m.file_size, @@ -200,7 +205,10 @@ def update_map( m = db.query(GenericMap).filter_by(id=map_id).first() if not m: raise HTTPException(404) - for field, value in data.model_dump(exclude_none=True).items(): + payload = data.model_dump(exclude_none=True) + tag_service.sync_tags_from_payload(db, "map", m.id, payload) + payload.pop("tags", None) # tags live in the shared-tag tables, not a column + for field, value in payload.items(): setattr(m, field, value) db.commit() return {"status": "ok"} diff --git a/backend/routers/opds/core.py b/backend/routers/opds/core.py index 2075ba6..6c65179 100644 --- a/backend/routers/opds/core.py +++ b/backend/routers/opds/core.py @@ -16,6 +16,7 @@ from ...config import get_db from ...models import Book, User +from ...services import tag_service _ATOM_NS = 'xmlns="http://www.w3.org/2005/Atom"' @@ -45,7 +46,7 @@ def _resolve_user(db, token: str) -> User: return user -def _book_to_entry(book: Book, token: str, base_url: str) -> str: +def _book_to_entry(book: Book, token: str, base_url: str, tags: list[str]) -> str: """Render a single Book as an OPDS Atom .""" title = escape(book.title or "Untitled") authors = book.authors or [] @@ -69,7 +70,7 @@ def _book_to_entry(book: Book, token: str, base_url: str) -> str: ) tags_xml = "".join( - f"" for t in (book.tags or []) + f"" for t in tags ) return ( @@ -150,7 +151,8 @@ def catalog_all(token: str, db: Session = Depends(get_db)) -> Response: f'' ) - entries = "".join(_book_to_entry(b, token, base_url) for b in books) + book_tags = tag_service.display_tags_for_resources(db, "book", [b.id for b in books]) + entries = "".join(_book_to_entry(b, token, base_url, book_tags.get(b.id, [])) for b in books) body = _feed_wrapper("All Books", feed_url, updated, links, entries) return Response(content=body, media_type=_ACQUISITION_TYPE) @@ -175,7 +177,9 @@ def book_entry(token: str, book_id: str, db: Session = Depends(get_db)) -> Respo f'' ) - entry = _book_to_entry(book, token, base_url) + entry = _book_to_entry( + book, token, base_url, tag_service.display_tags_for_resource(db, "book", book.id) + ) body = _feed_wrapper(book.title or "Book", feed_url, updated, links, entry) return Response(content=body, media_type=_ACQUISITION_TYPE) diff --git a/backend/routers/saved_filters/__init__.py b/backend/routers/saved_filters/__init__.py new file mode 100644 index 0000000..443cc36 --- /dev/null +++ b/backend/routers/saved_filters/__init__.py @@ -0,0 +1,29 @@ +"""Saved-filters package — per-user named sort/filter presets.""" +from fastapi import APIRouter + +from .core import ( + create_saved_filter, + delete_saved_filter, + list_saved_filters, + update_saved_filter, +) + +router = APIRouter(prefix="/saved-filters", tags=["saved-filters"]) + +__all__ = ["router"] + +router.add_api_route( + "", list_saved_filters, methods=["GET"], summary="List the user's saved filters" +) +router.add_api_route( + "", create_saved_filter, methods=["POST"], summary="Create/overwrite a saved filter" +) +router.add_api_route( + "/{filter_id}", + update_saved_filter, + methods=["PATCH"], + summary="Rename, re-save state, or set default", +) +router.add_api_route( + "/{filter_id}", delete_saved_filter, methods=["DELETE"], summary="Delete a saved filter" +) diff --git a/backend/routers/saved_filters/_schemas.py b/backend/routers/saved_filters/_schemas.py new file mode 100644 index 0000000..12d9fb7 --- /dev/null +++ b/backend/routers/saved_filters/_schemas.py @@ -0,0 +1,45 @@ +"""Pydantic schemas for the saved-filters API.""" +from typing import Any, Optional + +from pydantic import BaseModel, field_validator + +# Browsable content areas a saved filter can belong to. +VALID_SCOPES = {"systems", "books", "maps", "tokens", "audio"} + + +class SavedFilterCreate(BaseModel): + scope: str + name: str + state: dict[str, Any] = {} + is_default: bool = False + + @field_validator("scope") + @classmethod + def valid_scope(cls, v: str) -> str: + if v not in VALID_SCOPES: + raise ValueError(f"scope must be one of: {', '.join(sorted(VALID_SCOPES))}") + return v + + @field_validator("name") + @classmethod + def name_not_blank(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("name must not be blank") + return v + + +class SavedFilterUpdate(BaseModel): + name: Optional[str] = None + state: Optional[dict[str, Any]] = None + is_default: Optional[bool] = None + + @field_validator("name") + @classmethod + def name_not_blank(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + v = v.strip() + if not v: + raise ValueError("name must not be blank") + return v diff --git a/backend/routers/saved_filters/core.py b/backend/routers/saved_filters/core.py new file mode 100644 index 0000000..63001dd --- /dev/null +++ b/backend/routers/saved_filters/core.py @@ -0,0 +1,127 @@ +"""Saved-filter CRUD endpoints. + +Per-user named sort/filter presets, scoped to a library area +(systems/books/maps/tokens/audio). At most one preset per (user, scope) is the +default — the view the user lands on. Setting a preset default clears the flag +on any sibling in the same scope so the "one default per scope" invariant holds. +""" +from typing import Any, Optional + +from fastapi import Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from ...auth import CurrentUser, get_current_user +from ...config import get_db +from ...models import SavedFilter +from ._schemas import VALID_SCOPES, SavedFilterCreate, SavedFilterUpdate + + +def _serialize(f: SavedFilter) -> dict[str, Any]: + return { + "id": f.id, + "scope": f.scope, + "name": f.name, + "state": f.state or {}, + "is_default": bool(f.is_default), + } + + +def _clear_other_defaults(db: Session, user_id: str, scope: str, keep_id: str) -> None: + """Unset is_default on every other preset in this (user, scope).""" + others = ( + db.query(SavedFilter) + .filter( + SavedFilter.user_id == user_id, + SavedFilter.scope == scope, + SavedFilter.is_default == True, # noqa: E712 + SavedFilter.id != keep_id, + ) + .all() + ) + for o in others: + o.is_default = False + + +def list_saved_filters( + scope: Optional[str] = Query(None), + user: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +): + """List the current user's saved filters, optionally limited to one scope.""" + q = db.query(SavedFilter).filter_by(user_id=user.id) + if scope is not None: + if scope not in VALID_SCOPES: + raise HTTPException(400, "Invalid scope") + q = q.filter_by(scope=scope) + rows = q.order_by(SavedFilter.scope, SavedFilter.name).all() + return {"filters": [_serialize(f) for f in rows]} + + +def create_saved_filter( + body: SavedFilterCreate, + user: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Create a preset. Re-saving an existing (scope, name) overwrites its state.""" + existing = ( + db.query(SavedFilter) + .filter_by(user_id=user.id, scope=body.scope, name=body.name.strip()) + .first() + ) + if existing: + existing.state = body.state + if body.is_default: + existing.is_default = True + _clear_other_defaults(db, user.id, body.scope, existing.id) + db.commit() + return _serialize(existing) + + f = SavedFilter( + user_id=user.id, + scope=body.scope, + name=body.name.strip(), + state=body.state, + is_default=body.is_default, + ) + db.add(f) + db.flush() + if body.is_default: + _clear_other_defaults(db, user.id, body.scope, f.id) + db.commit() + return _serialize(f) + + +def update_saved_filter( + filter_id: str, + body: SavedFilterUpdate, + user: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Rename a preset, replace its state, and/or set it as the scope default.""" + f = db.query(SavedFilter).filter_by(id=filter_id, user_id=user.id).first() + if not f: + raise HTTPException(404, "Saved filter not found") + if body.name is not None: + f.name = body.name.strip() + if body.state is not None: + f.state = body.state + if body.is_default is not None: + f.is_default = body.is_default + if body.is_default: + _clear_other_defaults(db, user.id, f.scope, f.id) + db.commit() + return _serialize(f) + + +def delete_saved_filter( + filter_id: str, + user: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Delete one of the current user's saved filters.""" + f = db.query(SavedFilter).filter_by(id=filter_id, user_id=user.id).first() + if not f: + raise HTTPException(404, "Saved filter not found") + db.delete(f) + db.commit() + return {"status": "ok"} diff --git a/backend/routers/search/_helpers.py b/backend/routers/search/_helpers.py index 02f0441..3a56455 100644 --- a/backend/routers/search/_helpers.py +++ b/backend/routers/search/_helpers.py @@ -3,7 +3,36 @@ from sqlalchemy import String, cast, or_ -from ...models import Audio, AudioFolder, GenericMap, MapFolder, Token, TokenFolder +from ...models import ( + Audio, + AudioFolder, + GenericMap, + MapFolder, + ResourceTag, + Tag, + Token, + TokenFolder, +) +from ...services import tag_service + + +def _ids_matching_tag(db, resource_type: str, term: str) -> set: + """Ids of resources of ``resource_type`` whose shared tag matches ``term``. + + Matches the tag's display OR internal value against the ILIKE ``term`` + (``%q%``), so tag search works off the join table rather than the legacy JSON + column. + """ + rows = ( + db.query(ResourceTag.resource_id) + .join(Tag, Tag.id == ResourceTag.tag_id) + .filter( + ResourceTag.resource_type == resource_type, + or_(Tag.display.ilike(term), Tag.internal.ilike(term)), + ) + .all() + ) + return {r[0] for r in rows} # FTS5 snippet() wraps matches in these sentinels rather than literal @@ -55,14 +84,11 @@ def escape_snippet(raw: str) -> str: def _search_maps(db, q: str) -> list: term = f"%{q}%" + # Filename matches, plus items whose shared tag matches the term. + tag_ids = _ids_matching_tag(db, "map", term) direct = ( db.query(GenericMap) - .filter( - or_( - GenericMap.filename.ilike(term), - cast(GenericMap.tags, String).ilike(term), - ) - ) + .filter(or_(GenericMap.filename.ilike(term), GenericMap.id.in_(tag_ids))) .limit(50) .all() ) @@ -89,22 +115,25 @@ def _search_maps(db, q: str) -> list: seen.add(m.id) extra.append(m) + results = (direct + extra)[:50] + tags = tag_service.display_tags_for_resources(db, "map", [m.id for m in results]) return [ - {"id": m.id, "filename": m.filename, "relative_path": m.relative_path, "tags": m.tags} - for m in (direct + extra)[:50] + { + "id": m.id, + "filename": m.filename, + "relative_path": m.relative_path, + "tags": tags.get(m.id, []), + } + for m in results ] def _search_tokens(db, q: str) -> list: term = f"%{q}%" + tag_ids = _ids_matching_tag(db, "token", term) direct = ( db.query(Token) - .filter( - or_( - Token.filename.ilike(term), - cast(Token.tags, String).ilike(term), - ) - ) + .filter(or_(Token.filename.ilike(term), Token.id.in_(tag_ids))) .limit(50) .all() ) @@ -127,14 +156,22 @@ def _search_tokens(db, q: str) -> list: seen.add(t.id) extra.append(t) + results = (direct + extra)[:50] + tags = tag_service.display_tags_for_resources(db, "token", [t.id for t in results]) return [ - {"id": t.id, "filename": t.filename, "relative_path": t.relative_path, "tags": t.tags} - for t in (direct + extra)[:50] + { + "id": t.id, + "filename": t.filename, + "relative_path": t.relative_path, + "tags": tags.get(t.id, []), + } + for t in results ] def _search_audio(db, q: str) -> list: term = f"%{q}%" + tag_ids = _ids_matching_tag(db, "audio", term) direct = ( db.query(Audio) .filter( @@ -143,7 +180,7 @@ def _search_audio(db, q: str) -> list: Audio.title.ilike(term), Audio.artist.ilike(term), Audio.album.ilike(term), - cast(Audio.tags, String).ilike(term), + Audio.id.in_(tag_ids), ) ) .limit(50) @@ -168,13 +205,15 @@ def _search_audio(db, q: str) -> list: seen.add(a.id) extra.append(a) + results = (direct + extra)[:50] + tags = tag_service.display_tags_for_resources(db, "audio", [a.id for a in results]) return [ { "id": a.id, "filename": a.filename, "relative_path": a.relative_path, "title": a.title, - "tags": a.tags, + "tags": tags.get(a.id, []), } - for a in (direct + extra)[:50] + for a in results ] diff --git a/backend/routers/systems/_helpers.py b/backend/routers/systems/_helpers.py index 09e735e..5e57f44 100644 --- a/backend/routers/systems/_helpers.py +++ b/backend/routers/systems/_helpers.py @@ -27,14 +27,3 @@ def resolve_cover_book_id(db, system) -> str | None: if auto: cover_book_id = auto.id return cover_book_id - - -def _normalize_tags(tags: list[str]) -> list[str]: - seen: set[str] = set() - result: list[str] = [] - for t in tags: - lowered = t.strip().lower() - if lowered and lowered not in seen: - seen.add(lowered) - result.append(lowered) - return result diff --git a/backend/routers/systems/_schemas.py b/backend/routers/systems/_schemas.py index 1ecd970..99c6ca1 100644 --- a/backend/routers/systems/_schemas.py +++ b/backend/routers/systems/_schemas.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, field_validator -from ._helpers import _normalize_tags +from ...services import tag_service class PublisherEntry(BaseModel): @@ -11,27 +11,63 @@ class PublisherEntry(BaseModel): url: str = "" +class LinkEntry(BaseModel): + """A labeled link (generic URL or character-builder URL).""" + + label: str = "" + url: str = "" + + class BookFolderUpdate(BaseModel): path: str tags: list[str] @field_validator("tags", mode="before") @classmethod - def lowercase_tags(cls, v): - return _normalize_tags(v) + def dedupe_tags(cls, v): + # Keep the entered casing (dedupe by key); the book-folder handler + # registers catalog rows with this casing and stores internal keys. + return tag_service.dedupe_tags(v) class GameSystemUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None publishers: Optional[list[PublisherEntry]] = None + # Legacy single-value URL; still accepted for backward compatibility. character_builder_url: Optional[str] = None + character_builder_urls: Optional[list[LinkEntry]] = None + urls: Optional[list[LinkEntry]] = None tags: Optional[list[str]] = None + # Legacy single-value genre; still accepted. New clients send ``genres``. genre: Optional[str] = None + genres: Optional[list[str]] = None + dice_materials: Optional[list[str]] = None + system_family: Optional[str] = None + parent_system: Optional[str] = None + edition: Optional[str] = None + license: Optional[str] = None + year: Optional[int] = None cover_book_id: Optional[str] = None is_explicit: Optional[bool] = None @field_validator("tags", mode="before") @classmethod - def lowercase_tags(cls, v): - return _normalize_tags(v) if v is not None else v + def dedupe_tags(cls, v): + return tag_service.dedupe_tags(v) if v is not None else v + + @field_validator("genres", "dice_materials", mode="before") + @classmethod + def strip_list(cls, v): + """Trim and drop empties, preserving case (genres are display values).""" + if v is None: + return v + seen: set[str] = set() + out: list[str] = [] + for item in v: + s = str(item).strip() + key = s.lower() + if s and key not in seen: + seen.add(key) + out.append(s) + return out diff --git a/backend/routers/systems/_serializers.py b/backend/routers/systems/_serializers.py new file mode 100644 index 0000000..6fe1c84 --- /dev/null +++ b/backend/routers/systems/_serializers.py @@ -0,0 +1,88 @@ +"""Serialization helpers for game systems and their books (issue #202). + +Centralizes the field lists so ``list_systems``, ``get_system``, and any future +endpoint emit the same shape. +""" +from typing import Any + +from ...models import Book, GameSystem + + +def serialize_book(book: Book, tags: list[str] | None = None) -> dict[str, Any]: + """Serialize a Book to the API shape used by the system detail view. + + ``tags`` are the book's shared tags (display strings) — pass them in from a + batch ``tag_service`` lookup so serialization stays a pure, per-row mapping. + """ + return { + "id": book.id, + "title": book.title, + "filename": book.filename, + "category": book.category, + "description": book.description, + "page_count": book.page_count, + "file_size": book.file_size, + "mime_type": book.mime_type, + "authors": book.authors or [], + "artists": book.artists or [], + "genres": book.genres or [], + "publisher": book.publisher, + "publisher_url": book.publisher_url, + "urls": book.urls or [], + "isbn": book.isbn or "", + "version": book.version or "", + "language": book.language or "", + "license": book.license or "", + "year": book.year, + "month": book.month, + "day": book.day, + "indexed": book.indexed, + "index_failed": book.index_failed, + "index_error": book.index_error, + "ocr_indexed": book.index_error == "ocr", + "ocr_dpi": book.ocr_dpi, + "has_thumbnail": book.has_thumbnail, + "tags": tags if tags is not None else [], + "is_explicit": bool(book.is_explicit), + "is_missing": bool(book.is_missing), + "relative_path": book.relative_path, + } + + +def serialize_system_summary( + system: GameSystem, + book_count: int, + total_page_count: int, + cover_book_id: str | None, + tags: list[str] | None = None, +) -> dict[str, Any]: + """Serialize a GameSystem for the systems list (no book payload). + + ``tags`` are the system's shared tags (display strings) from a batch lookup. + """ + return { + "id": system.id, + "name": system.name, + "slug": system.slug, + "description": system.description, + "publishers": system.publishers or [], + "character_builder_url": system.character_builder_url, + "character_builder_urls": system.character_builder_urls or [], + "urls": system.urls or [], + "tags": tags if tags is not None else [], + "genre": system.genre, + "genres": system.genres or [], + "dice_materials": system.dice_materials or [], + "system_family": system.system_family or "", + "parent_system": system.parent_system or "", + "edition": system.edition or "", + "license": system.license or "", + "year": system.year, + "book_count": book_count, + "total_page_count": total_page_count, + "cover_image": system.cover_image, + "cover_book_id": cover_book_id, + "is_explicit": bool(system.is_explicit), + "is_system_agnostic": bool(system.is_system_agnostic), + "is_one_page": bool(system.is_one_page), + } diff --git a/backend/routers/systems/core.py b/backend/routers/systems/core.py index 914a43f..a76073b 100644 --- a/backend/routers/systems/core.py +++ b/backend/routers/systems/core.py @@ -1,15 +1,43 @@ """Game system endpoint handlers.""" -from fastapi import Depends, HTTPException +from typing import Optional + +from fastapi import Depends, HTTPException, Query +from sqlalchemy import func from sqlalchemy.orm import Session from ...auth import CurrentUser, get_current_user, require_gm_or_admin from ...config import get_db from ...models import Book, BookFolder, GameSystem, User +from ...services import tag_service from ._helpers import resolve_cover_book_id from ._schemas import BookFolderUpdate, GameSystemUpdate +from ._serializers import serialize_book, serialize_system_summary + +# Sort keys accepted by list_systems. Value is the summary dict key to sort on. +_SYSTEM_SORT_KEYS = {"name", "book_count", "page_count", "year"} +# Sort keys accepted for a system's books (get_system). +_BOOK_SORT_KEYS = {"title", "page_count", "year"} + + +def _has_value(field, wanted: str) -> bool: + """Case-insensitive membership test against a stringy/list JSON field.""" + if field is None: + return False + wanted = wanted.strip().lower() + if isinstance(field, list): + return any(str(v).strip().lower() == wanted for v in field) + return str(field).strip().lower() == wanted def list_systems( + sort: str = Query("name"), + order: str = Query("asc"), + genre: Optional[str] = Query(None), + family: Optional[str] = Query(None), + parent_system: Optional[str] = Query(None), + edition: Optional[str] = Query(None), + license: Optional[str] = Query(None), + explicit: Optional[bool] = Query(None), current_user: CurrentUser = Depends(get_current_user), db: Session = Depends(get_db), ): @@ -18,38 +46,78 @@ def list_systems( bool(user.allow_explicit) if user and user.allow_explicit is not None else True ) - systems = db.query(GameSystem).order_by(GameSystem.name).all() + # Per-system book count + total page count in one grouped query. + agg_q = db.query( + Book.game_system_id, + func.count(Book.id), + func.coalesce(func.sum(Book.page_count), 0), + ) + if not can_see_explicit: + agg_q = agg_q.filter(Book.is_explicit != True) # noqa: E712 + agg = { + gsid: (count, pages) + for gsid, count, pages in agg_q.group_by(Book.game_system_id).all() + } + + systems = db.query(GameSystem).all() + system_tags = tag_service.display_tags_for_resources(db, "system", [s.id for s in systems]) result = [] for s in systems: if s.is_explicit and not can_see_explicit: continue - book_q = db.query(Book).filter_by(game_system_id=s.id) - if not can_see_explicit: - book_q = book_q.filter(Book.is_explicit != True) - book_count = book_q.count() + if explicit is not None and bool(s.is_explicit) != explicit: + continue + if genre and not _has_value(s.genres, genre): + continue + if family and not _has_value(s.system_family, family): + continue + if parent_system and not _has_value(s.parent_system, parent_system): + continue + if edition and not _has_value(s.edition, edition): + continue + if license and not _has_value(s.license, license): + continue + book_count, total_pages = agg.get(s.id, (0, 0)) cover_book_id = resolve_cover_book_id(db, s) result.append( - { - "id": s.id, - "name": s.name, - "slug": s.slug, - "description": s.description, - "publishers": s.publishers or [], - "character_builder_url": s.character_builder_url, - "tags": s.tags or [], - "genre": s.genre, - "book_count": book_count, - "cover_image": s.cover_image, - "cover_book_id": cover_book_id, - "is_explicit": bool(s.is_explicit), - "is_system_agnostic": bool(s.is_system_agnostic), - } + serialize_system_summary( + s, + book_count, + int(total_pages or 0), + cover_book_id, + tags=system_tags.get(s.id, []), + ) ) + + result = _sort_systems(result, sort, order) return result +def _sort_systems(rows: list[dict], sort: str, order: str) -> list[dict]: + """Sort serialized system rows by the requested key (name default).""" + key = sort if sort in _SYSTEM_SORT_KEYS else "name" + reverse = order == "desc" + if key == "name": + return sorted(rows, key=lambda r: r["name"].lower(), reverse=reverse) + if key == "page_count": + return sorted(rows, key=lambda r: r["total_page_count"], reverse=reverse) + if key == "year": + # Systems with no year sort last regardless of direction. + return sorted( + rows, + key=lambda r: (r["year"] is None, r["year"] or 0), + reverse=reverse, + ) + return sorted(rows, key=lambda r: r[key], reverse=reverse) + + def get_system( system_id: str, + book_sort: str = Query("category"), + book_order: str = Query("asc"), + explicit: Optional[bool] = Query(None), + genre: Optional[str] = Query(None), + category: Optional[str] = Query(None), current_user: CurrentUser = Depends(get_current_user), db: Session = Depends(get_db), ): @@ -68,8 +136,9 @@ def get_system( book_q = db.query(Book).filter_by(game_system_id=system.id) if not can_see_explicit: book_q = book_q.filter(Book.is_explicit != True) - books = book_q.order_by(Book.category, Book.title).all() + books = book_q.all() + # Cover resolution ignores the sort/filter args (must be stable). cover_book_id = system.cover_book_id if not cover_book_id: auto = next((b for b in books if b.category == "core" and b.has_thumbnail), None) @@ -77,47 +146,45 @@ def get_system( auto = next((b for b in books if b.has_thumbnail), None) if auto: cover_book_id = auto.id - return { - "id": system.id, - "name": system.name, - "slug": system.slug, - "description": system.description, - "publishers": system.publishers or [], - "character_builder_url": system.character_builder_url, - "tags": system.tags or [], - "genre": system.genre, - "cover_image": system.cover_image, - "cover_book_id": cover_book_id, - "is_explicit": bool(system.is_explicit), - "is_system_agnostic": bool(system.is_system_agnostic), - "books": [ - { - "id": b.id, - "title": b.title, - "filename": b.filename, - "category": b.category, - "description": b.description, - "page_count": b.page_count, - "file_size": b.file_size, - "mime_type": b.mime_type, - "authors": b.authors or [], - "publisher": b.publisher, - "publisher_url": b.publisher_url, - "year": b.year, - "indexed": b.indexed, - "index_failed": b.index_failed, - "index_error": b.index_error, - "ocr_indexed": b.index_error == "ocr", - "ocr_dpi": b.ocr_dpi, - "has_thumbnail": b.has_thumbnail, - "tags": b.tags or [], - "is_explicit": bool(b.is_explicit), - "is_missing": bool(b.is_missing), - "relative_path": b.relative_path, - } - for b in books - ], - } + + # Filter then sort the returned book list. + if explicit is not None: + books = [b for b in books if bool(b.is_explicit) == explicit] + if category: + books = [b for b in books if b.category == category] + if genre: + books = [b for b in books if _has_value(b.genres, genre)] + books = _sort_books(books, book_sort, book_order) + + system_tags = tag_service.display_tags_for_resource(db, "system", system.id) + book_tags = tag_service.display_tags_for_resources(db, "book", [b.id for b in books]) + summary = serialize_system_summary( + system, + book_count=len(books), + total_page_count=sum(b.page_count or 0 for b in books), + cover_book_id=cover_book_id, + tags=system_tags, + ) + summary["books"] = [serialize_book(b, tags=book_tags.get(b.id, [])) for b in books] + return summary + + +def _sort_books(books: list[Book], sort: str, order: str) -> list[Book]: + """Sort ORM Book rows by the requested key (category+title default).""" + reverse = order == "desc" + key = sort if sort in _BOOK_SORT_KEYS else "category" + if key == "title": + return sorted(books, key=lambda b: b.title.lower(), reverse=reverse) + if key == "page_count": + return sorted(books, key=lambda b: b.page_count or 0, reverse=reverse) + if key == "year": + return sorted( + books, + key=lambda b: (b.year is None, b.year or 0, b.title.lower()), + reverse=reverse, + ) + # Default: group by category, then title (both ascending, ignoring order). + return sorted(books, key=lambda b: (b.category, b.title.lower())) def list_book_folders( @@ -129,7 +196,12 @@ def list_book_folders( if not system: raise HTTPException(404, "System not found") folders = db.query(BookFolder).filter(BookFolder.path.like(f"{system_id}/%")).all() - return {"folders": [{"path": f.path, "tags": f.tags or []} for f in folders]} + return { + "folders": [ + {"path": f.path, "tags": tag_service.folder_display_tags(db, f.tags or [])} + for f in folders + ] + } def update_book_folder( @@ -138,13 +210,11 @@ def update_book_folder( _: CurrentUser = Depends(require_gm_or_admin), # noqa: ARG001, db: Session = Depends(get_db), ): - folder = db.query(BookFolder).filter_by(path=data.path).first() - if folder: - folder.tags = data.tags - else: - db.add(BookFolder(path=data.path, tags=data.tags)) + internals = tag_service.upsert_folder_tags( + db, BookFolder, data.path, data.tags, category="book" + ) db.commit() - return {"path": data.path, "tags": data.tags} + return {"path": data.path, "tags": internals} def update_system( @@ -156,9 +226,11 @@ def update_system( system = db.query(GameSystem).filter_by(id=system_id).first() if not system: raise HTTPException(404, "System not found") + # model_dump serializes nested Pydantic models (publishers, urls, + # character_builder_urls) to plain dicts, which SQLAlchemy stores as JSON. payload = data.model_dump(exclude_none=True) - if "publishers" in payload: - payload["publishers"] = [p if isinstance(p, dict) else p for p in payload["publishers"]] + tag_service.sync_tags_from_payload(db, "system", system.id, payload) + payload.pop("tags", None) # tags live in the shared-tag tables, not a column for field, value in payload.items(): setattr(system, field, value) db.commit() diff --git a/backend/routers/tags/__init__.py b/backend/routers/tags/__init__.py new file mode 100644 index 0000000..e544152 --- /dev/null +++ b/backend/routers/tags/__init__.py @@ -0,0 +1,28 @@ +"""Tags package — registers the shared-tag routes on a single router (issue #235).""" +from fastapi import APIRouter + +from .core import ( + create_tag, + delete_tag, + list_tags, + merge_tag, + tag_items, + update_tag_display, +) + +router = APIRouter(prefix="/tags", tags=["tags"]) + +router.add_api_route("", list_tags, methods=["GET"], summary="List tags") +router.add_api_route("", create_tag, methods=["POST"], summary="Create a tag", status_code=201) +router.add_api_route( + "/{internal}/items", tag_items, methods=["GET"], summary="Items carrying a tag" +) +router.add_api_route( + "/{internal}", update_tag_display, methods=["PATCH"], summary="Rename a tag's display value" +) +router.add_api_route( + "/{internal}/merge", merge_tag, methods=["POST"], summary="Merge a tag into another" +) +router.add_api_route( + "/{internal}", delete_tag, methods=["DELETE"], summary="Delete a tag", status_code=204 +) diff --git a/backend/routers/tags/_helpers.py b/backend/routers/tags/_helpers.py new file mode 100644 index 0000000..0fe63c7 --- /dev/null +++ b/backend/routers/tags/_helpers.py @@ -0,0 +1,103 @@ +"""Enrichment helpers for the tags API: turn (type, id) refs into display items.""" +from sqlalchemy.orm import Session + +from ...models import Audio, Book, GameSystem, GenericMap, Token, User +from ..systems._helpers import resolve_cover_book_id + + +def can_see_explicit(db: Session, user_id: str) -> bool: + user = db.query(User).filter_by(id=user_id).first() + return bool(user.allow_explicit) if user and user.allow_explicit is not None else True + + +def enrich_tagged_items( + db: Session, refs: list[dict], *, see_explicit: bool +) -> list[dict]: + """Enrich ``[{resource_type, resource_id}]`` refs into display items. + + Mirrors the favorites enrichment shape so the Tags page can reuse the same + cards. Explicit systems/books/tokens are dropped for users who can't see + them. Missing rows (stale links) are skipped. + """ + book_ids = [r["resource_id"] for r in refs if r["resource_type"] == "book"] + map_ids = [r["resource_id"] for r in refs if r["resource_type"] == "map"] + token_ids = [r["resource_id"] for r in refs if r["resource_type"] == "token"] + audio_ids = [r["resource_id"] for r in refs if r["resource_type"] == "audio"] + system_ids = [r["resource_id"] for r in refs if r["resource_type"] == "system"] + + books = {b.id: b for b in db.query(Book).filter(Book.id.in_(book_ids))} + maps = {m.id: m for m in db.query(GenericMap).filter(GenericMap.id.in_(map_ids))} + tokens = {t.id: t for t in db.query(Token).filter(Token.id.in_(token_ids))} + audio = {a.id: a for a in db.query(Audio).filter(Audio.id.in_(audio_ids))} + systems = {s.id: s for s in db.query(GameSystem).filter(GameSystem.id.in_(system_ids))} + + items: list[dict] = [] + for r in refs: + rid, rtype = r["resource_id"], r["resource_type"] + if rtype == "book" and rid in books: + b = books[rid] + if b.is_explicit and not see_explicit: + continue + items.append( + { + "item_type": "book", + "item_id": b.id, + "title": b.title, + "category": b.category, + "has_thumbnail": b.has_thumbnail, + "page_count": b.page_count, + "indexed": b.indexed, + "index_failed": b.index_failed, + } + ) + elif rtype == "map" and rid in maps: + m = maps[rid] + items.append( + { + "item_type": "map", + "item_id": m.id, + "filename": m.filename, + "has_thumbnail": m.has_thumbnail, + "file_size": m.file_size, + } + ) + elif rtype == "token" and rid in tokens: + t = tokens[rid] + if t.is_explicit and not see_explicit: + continue + items.append( + { + "item_type": "token", + "item_id": t.id, + "filename": t.filename, + "has_thumbnail": t.has_thumbnail, + "file_size": t.file_size, + } + ) + elif rtype == "audio" and rid in audio: + a = audio[rid] + items.append( + { + "item_type": "audio", + "item_id": a.id, + "filename": a.filename, + "title": a.title or "", + "duration": a.duration or 0.0, + "has_artwork": bool(a.has_artwork), + "file_size": a.file_size, + } + ) + elif rtype == "system" and rid in systems: + s = systems[rid] + if s.is_explicit and not see_explicit: + continue + items.append( + { + "item_type": "system", + "item_id": s.id, + "name": s.name, + "publishers": s.publishers or [], + "cover_book_id": resolve_cover_book_id(db, s), + } + ) + return items diff --git a/backend/routers/tags/_schemas.py b/backend/routers/tags/_schemas.py new file mode 100644 index 0000000..09580bf --- /dev/null +++ b/backend/routers/tags/_schemas.py @@ -0,0 +1,23 @@ +"""Pydantic schemas for the tags API (issue #235).""" +from typing import Optional + +from pydantic import BaseModel + + +class TagDisplayUpdate(BaseModel): + """Rename a tag's human-facing display value (internal key is immutable).""" + + display: str + + +class TagMerge(BaseModel): + """Merge this tag into another, re-pointing all its resource links.""" + + into: str # target tag's internal key (or any casing of it) + + +class TagCreate(BaseModel): + """Create a tag up-front (optional; tags are also created on first use).""" + + value: str + display: Optional[str] = None diff --git a/backend/routers/tags/core.py b/backend/routers/tags/core.py new file mode 100644 index 0000000..15ed17b --- /dev/null +++ b/backend/routers/tags/core.py @@ -0,0 +1,261 @@ +"""Tags API endpoint handlers (issue #235). + +The tags page and cross-resource tag features read from the shared-tag tables via +``tag_service``. Listing and per-tag item views are readable by any authenticated +user (explicit items are filtered per-user); mutations require gm/admin. +""" +from typing import Optional + +from fastapi import Depends, HTTPException, Query +from sqlalchemy import func +from sqlalchemy.orm import Session + +from ...auth import CurrentUser, get_current_user, require_gm_or_admin +from ...config import get_db +from ...models import RESOURCE_TYPES, Favorite, ResourceTag, Tag +from ...services import tag_service +from ._helpers import can_see_explicit, enrich_tagged_items +from ._schemas import TagCreate, TagDisplayUpdate, TagMerge + + +def _merge_folder_tags(db: Session, shared: list[dict], in_use_by: Optional[str]) -> list[dict]: + """Fold folder-derived tags into a shared-tag listing, keyed by internal. + + A tag present in both keeps its shared display and sums the two counts + (folder items not already carrying the shared tag add to the total); a + folder-only tag is added with its resolved item count. A tag's ``category`` + is its *effective* category: the stored ``Tag.category`` reconciled with the + resource types of any folders carrying it, so e.g. a tag on a book (direct) + and a map folder resolves to ``shared``. Result is sorted by display, + case-insensitively. + """ + # Resolve folder tags across all types (unscoped), so a tag's effective + # category reflects every type it appears on even when the listing itself is + # scoped to one page. + folder = tag_service.folder_tags_in_use(db) + folder_types: dict[str, set[str]] = { + internal: {r["resource_type"] for r in info["refs"]} + for internal, info in folder.items() + } + # Folder refs to fold into counts respect the page scope (in_use_by). + scoped_folder = ( + folder if in_use_by is None else tag_service.folder_tags_in_use(db, in_use_by) + ) + + by_internal = {t["internal"]: dict(t) for t in shared} + for entry in by_internal.values(): + entry["category"] = tag_service.effective_category( + entry.get("category"), folder_types.get(entry["internal"], set()) + ) + if entry["internal"] in scoped_folder: + entry["count"] += len(scoped_folder[entry["internal"]]["refs"]) + for internal, info in scoped_folder.items(): + if internal in by_internal: + continue + # A folder-only tag has no shared-tag row; its category is derived purely + # from the folder resource types it appears on. + by_internal[internal] = { + "internal": internal, + "display": info["display"], + "category": tag_service.effective_category(None, folder_types.get(internal, set())), + "count": len(info["refs"]), + } + return sorted(by_internal.values(), key=lambda t: t["display"].lower()) + + +def list_tags( + in_use_by: Optional[str] = Query( + None, + description=( + "Restrict to tags used on this resource type (system|book|map|token|audio); " + "each result carries a usage count. Omit for all tags." + ), + ), + user: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +): + """All tags, or (with ``in_use_by``) just those used on a resource type. + + Folder-derived tags (from ``tags.json``/folder tagging) are merged in and + counted by the items they cover, so they appear in the tags view alongside + shared item tags. Each tag carries ``is_favorite`` for the current user. + """ + if in_use_by is not None and in_use_by not in RESOURCE_TYPES: + raise HTTPException(400, f"in_use_by must be one of: {', '.join(sorted(RESOURCE_TYPES))}") + + if in_use_by is not None: + shared = tag_service.tags_in_use(db, in_use_by) + else: + # All shared tags (used or not), with usage counts. + counts = { + row[0]: row[1] + for row in db.query(ResourceTag.tag_id, func.count(ResourceTag.id)) + .group_by(ResourceTag.tag_id) + .all() + } + shared = [ + { + "internal": t.internal, + "display": t.display, + "category": t.category, + "count": counts.get(t.id, 0), + } + for t in db.query(Tag).all() + ] + + merged = _merge_folder_tags(db, shared, in_use_by) + fav = { + r.item_id + for r in db.query(Favorite).filter_by(user_id=user.id, item_type="tag").all() + } + for tg in merged: + tg["is_favorite"] = tg["internal"] in fav + return {"tags": merged} + + +def tag_items( + internal: str, + resource_type: Optional[str] = Query( + None, description="Restrict returned items to this resource type." + ), + user: CurrentUser = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Every item carrying the given tag, enriched like the favorites view. + + Includes items that carry the tag directly (shared tag) and items that + inherit it from a folder tag (``tags.json``/folder tagging). + """ + if resource_type is not None and resource_type not in RESOURCE_TYPES: + raise HTTPException( + 400, f"resource_type must be one of: {', '.join(sorted(RESOURCE_TYPES))}" + ) + key = tag_service.normalize_internal(internal) + tag = db.query(Tag).filter(Tag.internal == key).first() + see_explicit = can_see_explicit(db, user.id) + + # Directly-tagged items (shared tag). Folder-derived items are shown as folder + # groups below rather than mixed into the flat item list. + direct_refs = tag_service.resources_for_tag(db, internal, resource_type=resource_type) + + # Folders carrying the tag, each rendered with everything inside (issue #235 + # follow-up: show the folder like the media pages do). + folder_groups = tag_service.folders_for_tag(db, key, resource_type=resource_type) + + if tag is None and not folder_groups and not direct_refs: + raise HTTPException(404, "Tag not found") + + display = tag.display if tag is not None else key + if display == key: + # Folder-only tag: use the folder's display casing when available. + fmeta = tag_service.folder_tags_in_use(db, resource_type).get(key) + if fmeta: + display = fmeta["display"] + + # Effective category: reconcile the stored category (direct usage) with every + # media-folder type the tag appears on (folder tags never promote the stored + # row), so e.g. a book tag also on a map folder resolves to shared. + folder_types = {g["resource_type"] for g in tag_service.folders_for_tag(db, key)} + category = tag_service.effective_category( + tag.category if tag is not None else None, folder_types + ) + + items = enrich_tagged_items(db, direct_refs, see_explicit=see_explicit) + folders = [ + { + "resource_type": g["resource_type"], + "path": g["path"], + "items": enrich_tagged_items(db, g["items"], see_explicit=see_explicit), + } + for g in folder_groups + ] + return { + "internal": key, + "display": display, + "category": category, + "items": items, + "folders": folders, + } + + +def create_tag( + body: TagCreate, + _: CurrentUser = Depends(require_gm_or_admin), + db: Session = Depends(get_db), +): + """Create a tag up-front (idempotent by internal key).""" + tag = tag_service.get_or_create_tag(db, body.value, display=body.display) + if tag is None: + raise HTTPException(400, "Tag value must not be blank") + db.commit() + return {"internal": tag.internal, "display": tag.display, "category": tag.category} + + +def update_tag_display( + internal: str, + body: TagDisplayUpdate, + _: CurrentUser = Depends(require_gm_or_admin), + db: Session = Depends(get_db), +): + """Rename a tag. Its ``internal`` match key follows the new display when the + normalized form changes (so a typo fix like ``freinds`` → ``friends`` also + fixes search-by-internal); if that key already exists, the tags are merged.""" + if not body.display.strip(): + raise HTTPException(400, "display must not be blank") + tag = tag_service.rename_tag(db, internal, body.display) + if tag is None: + raise HTTPException(404, "Tag not found") + db.commit() + return {"internal": tag.internal, "display": tag.display} + + +def merge_tag( + internal: str, + body: TagMerge, + _: CurrentUser = Depends(require_gm_or_admin), + db: Session = Depends(get_db), +): + """Merge this tag into another: re-point all links, then delete this tag.""" + src = db.query(Tag).filter(Tag.internal == tag_service.normalize_internal(internal)).first() + if src is None: + raise HTTPException(404, "Tag not found") + dst = tag_service.get_or_create_tag(db, body.into) + if dst is None: + raise HTTPException(400, "Merge target must not be blank") + if dst.id == src.id: + raise HTTPException(400, "Cannot merge a tag into itself") + + # Re-point every link from src → dst, skipping ones that would duplicate. + existing = { + (r.resource_type, r.resource_id) + for r in db.query(ResourceTag).filter(ResourceTag.tag_id == dst.id) + } + for link in db.query(ResourceTag).filter(ResourceTag.tag_id == src.id).all(): + if (link.resource_type, link.resource_id) in existing: + db.delete(link) + else: + link.tag_id = dst.id + db.query(Tag).filter(Tag.id == src.id).delete(synchronize_session=False) + db.commit() + return {"internal": dst.internal, "display": dst.display} + + +def delete_tag( + internal: str, + _: CurrentUser = Depends(require_gm_or_admin), + db: Session = Depends(get_db), +): + """Delete a tag: unlink it from every resource and strip it from any folder + tags. Folder-derived tags (no shared-tag row) are still handled — their folder + associations are removed (a rescan may reapply them from ``tags.json``).""" + key = tag_service.normalize_internal(internal) + tag = db.query(Tag).filter(Tag.internal == key).first() + folders_changed = tag_service.remove_tag_from_folders(db, key) + if tag is None and folders_changed == 0: + raise HTTPException(404, "Tag not found") + if tag is not None: + db.query(ResourceTag).filter(ResourceTag.tag_id == tag.id).delete( + synchronize_session=False + ) + db.query(Tag).filter(Tag.id == tag.id).delete(synchronize_session=False) + db.commit() diff --git a/backend/routers/tokens/_schemas.py b/backend/routers/tokens/_schemas.py index c722ad3..31b60ff 100644 --- a/backend/routers/tokens/_schemas.py +++ b/backend/routers/tokens/_schemas.py @@ -2,16 +2,7 @@ from typing import Optional from pydantic import BaseModel, field_validator - -def _normalize_tags(tags: list[str]) -> list[str]: - seen = set() - result = [] - for t in tags: - lowered = t.strip().lower() - if lowered and lowered not in seen: - seen.add(lowered) - result.append(lowered) - return result +from ...services import tag_service class TokenUpdate(BaseModel): @@ -21,8 +12,8 @@ class TokenUpdate(BaseModel): @field_validator("tags", mode="before") @classmethod - def lowercase_tags(cls, v): - return _normalize_tags(v) if v is not None else v + def dedupe_tags(cls, v): + return tag_service.dedupe_tags(v) if v is not None else v class FolderTagsUpdate(BaseModel): @@ -31,5 +22,7 @@ class FolderTagsUpdate(BaseModel): @field_validator("tags", mode="before") @classmethod - def lowercase_tags(cls, v): - return _normalize_tags(v) + def dedupe_tags(cls, v): + # Keep the entered casing (dedupe by key); the folder-update handler + # registers catalog rows with this casing and stores internal keys. + return tag_service.dedupe_tags(v) diff --git a/backend/routers/tokens/core.py b/backend/routers/tokens/core.py index 03b8df7..958e3c2 100644 --- a/backend/routers/tokens/core.py +++ b/backend/routers/tokens/core.py @@ -11,6 +11,7 @@ from ...config import THUMB_DIR, get_db from ...models import Token, TokenFolder +from ...services import tag_service from ...auth import require_gm_or_admin, get_current_user, CurrentUser from ...indexer import slugify from .._media_access import assert_media_access @@ -32,6 +33,7 @@ def list_tokens( q = q.filter(Token.is_explicit != True) total = q.count() tokens = q.order_by(Token.filename).offset(offset).limit(limit).all() + token_tags = tag_service.display_tags_for_resources(db, "token", [t.id for t in tokens]) return { "total": total, "tokens": [ @@ -40,7 +42,7 @@ def list_tokens( "filename": t.filename, "relative_path": t.relative_path, "description": t.description, - "tags": t.tags or [], + "tags": token_tags.get(t.id, []), "file_size": t.file_size, "has_thumbnail": t.has_thumbnail, "is_explicit": bool(t.is_explicit), @@ -53,7 +55,12 @@ def list_tokens( def list_token_folders(db: Session = Depends(get_db)): folders = db.query(TokenFolder).all() - return {"folders": [{"path": f.path, "tags": f.tags or []} for f in folders]} + return { + "folders": [ + {"path": f.path, "tags": tag_service.folder_display_tags(db, f.tags or [])} + for f in folders + ] + } def update_token_folder( @@ -61,13 +68,11 @@ def update_token_folder( _: CurrentUser = Depends(require_gm_or_admin), db: Session = Depends(get_db), ): - folder = db.query(TokenFolder).filter_by(path=data.path).first() - if folder: - folder.tags = data.tags - else: - db.add(TokenFolder(path=data.path, tags=data.tags)) + internals = tag_service.upsert_folder_tags( + db, TokenFolder, data.path, data.tags, category="token" + ) db.commit() - return {"path": data.path, "tags": data.tags} + return {"path": data.path, "tags": internals} def get_token( @@ -94,9 +99,9 @@ def get_token( "filename": t.filename, "relative_path": t.relative_path, "folder_path": folder_path, - "folder_tags": folder.tags if folder else [], + "folder_tags": tag_service.folder_display_tags(db, folder.tags if folder else []), "description": t.description, - "tags": t.tags or [], + "tags": tag_service.display_tags_for_resource(db, "token", t.id), "file_size": t.file_size, "has_thumbnail": t.has_thumbnail, "is_explicit": bool(t.is_explicit), @@ -152,7 +157,10 @@ def update_token( t = db.query(Token).filter_by(id=token_id).first() if not t: raise HTTPException(404) - for field, value in data.model_dump(exclude_none=True).items(): + payload = data.model_dump(exclude_none=True) + tag_service.sync_tags_from_payload(db, "token", t.id, payload) + payload.pop("tags", None) # tags live in the shared-tag tables, not a column + for field, value in payload.items(): setattr(t, field, value) db.commit() return {"status": "ok"} diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..34da69c --- /dev/null +++ b/backend/services/__init__.py @@ -0,0 +1 @@ +"""Service-layer helpers that encapsulate cross-router domain logic.""" diff --git a/backend/services/tag_service.py b/backend/services/tag_service.py new file mode 100644 index 0000000..df41b00 --- /dev/null +++ b/backend/services/tag_service.py @@ -0,0 +1,802 @@ +"""Shared-tag domain logic (issue #235). + +All tag reads/writes across resources funnel through here so matching is always +by the lowercased ``internal`` key and the display casing stays consistent. A tag +is created the first time an internal key is seen; its display value defaults to +the exact casing first entered and is only changed explicitly (tags page). +""" +from __future__ import annotations + +from typing import Iterable, Optional + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from ..models import ( + RESOURCE_TYPES, + SHARED_CATEGORY, + Audio, + AudioFolder, + Book, + BookFolder, + GenericMap, + MapFolder, + ResourceTag, + Tag, + Token, + TokenFolder, +) + +# Media folders carry their own JSON ``tags`` (folder tagging is separate from +# shared item tags). Each maps to the item model + resource_type it contains, so +# folder tags can be surfaced in the tags view as tags on those items (issue #235 +# follow-up). A folder's ``path`` is collection-relative (e.g. "Swamps") and an +# item lives under it when its ``relative_path`` contains ``//``. +_FOLDER_SOURCES = [ + (MapFolder, GenericMap, "map"), + (TokenFolder, Token, "token"), + (AudioFolder, Audio, "audio"), +] + +# Book subcategory folders (issue #235 follow-up) use a different addressing +# scheme than media folders: a ``BookFolder.path`` is +# ``{system_id}/{category}/{subfolder…}`` and a book "lives under" it when its +# ``game_system_id`` + ``category`` match and its own subfolder path is at/below +# that folder's subfolder path. They're resolved separately from +# ``_FOLDER_SOURCES`` but surfaced as ``book``-type folder tags all the same. +_BOOK_RESOURCE_TYPE = "book" + + +def _book_subfolder_segments(relative_path: str) -> list[str]: + """The subfolder segments of a book path, i.e. the parts after the category + dir and before the filename. + + ``relative_path`` is ``books/{SystemName}/{categoryDir}///``; the + subfolder segments are ``[a, b]`` (empty when the book sits directly in the + category dir). + """ + parts = (relative_path or "").replace("\\", "/").split("/") + return parts[3:-1] + + +def _book_folder_ancestor_paths(system_id: str, category: str, relative_path: str) -> set[str]: + """Every ``BookFolder.path`` a book belongs to. + + For subfolder segments ``[a, b]`` the book belongs to + ``{system_id}/{category}/a`` and ``{system_id}/{category}/a/b``. Books with no + subfolder (directly in the category dir) belong to no book folder. + """ + segs = _book_subfolder_segments(relative_path) + prefix = f"{system_id}/{category}" + return {prefix + "/" + "/".join(segs[: i + 1]) for i in range(len(segs))} + + +def _book_folder_display(path: str) -> str: + """Display casing for a book folder: its last path segment (the folder name).""" + return path.rstrip("/").split("/")[-1] if path else path + + +def normalize_internal(raw: str) -> str: + """The match key for a tag: stripped and lowercased.""" + return str(raw).strip().lower() + + +def default_display(raw: str) -> str: + """The display value to store for a brand-new tag: the entered text, trimmed. + + We keep the user's own casing rather than force Title Case, so "GM Screen" + stays "GM Screen". Purely programmatic callers may pass already-cased text. + """ + return str(raw).strip() + + +def dedupe_tags(tags: Iterable[str]) -> list[str]: + """Strip and de-duplicate tags by lowercased key, keeping first-seen casing. + + Used by the tag-accepting request schemas: the display casing is preserved + here (the service lowercases only the internal match key), so callers must + NOT lowercase up front. + """ + seen: set[str] = set() + result: list[str] = [] + for t in tags or []: + stripped = str(t).strip() + key = stripped.lower() + if key and key not in seen: + seen.add(key) + result.append(stripped) + return result + + +def tag_dict(tag: Tag) -> dict: + """Serialise a tag to the API shape used everywhere: {internal, display, category}.""" + return {"internal": tag.internal, "display": tag.display, "category": tag.category} + + +def get_or_create_tag( + db: Session, + raw: str, + *, + display: Optional[str] = None, + category: str = SHARED_CATEGORY, +) -> Optional[Tag]: + """Return the existing tag for ``raw``'s internal key, creating it if absent. + + Returns ``None`` for blank input. When creating, the display value is + ``display`` if given, else the entered casing, and ``category`` is the + resource type it's first used in (defaults to ``shared`` for programmatic + callers). Matching an existing tag never rewrites its display value; its + category is promoted to ``shared`` when it's now used in a different category + (see :func:`_promote_category`). + """ + internal = normalize_internal(raw) + if not internal: + return None + tag = db.query(Tag).filter(Tag.internal == internal).first() + if tag is None: + tag = Tag( + internal=internal, + display=(display or default_display(raw)) or internal, + category=category, + ) + db.add(tag) + db.flush() # assign id without committing; caller owns the transaction + else: + _promote_category(tag, category) + return tag + + +def register_folder_tags( + db: Session, raw_tags: Iterable[str], *, category: str = SHARED_CATEGORY +) -> list[str]: + """Ensure a ``Tag`` catalog row exists for each folder tag, returning the + de-duplicated **internal** keys to store on the folder record. + + Folder tags are stored on the ``*_folders``/``book_folders`` JSON columns as + internal keys; their display casing lives in the catalog. A new tag is created + with its entered casing as the default display; an existing tag keeps its + display (never overwritten) and may be promoted to ``shared``. Callers own the + transaction (no commit here). + """ + seen: set[str] = set() + internals: list[str] = [] + for raw in raw_tags or []: + internal = normalize_internal(raw) + if not internal or internal in seen: + continue + seen.add(internal) + get_or_create_tag(db, raw, category=category) + internals.append(internal) + return internals + + +def upsert_folder_tags( + db: Session, folder_model: type, path: str, raw_tags: Iterable[str], *, category: str +) -> list[str]: + """Set a folder record's tags to the given list, registering catalog rows. + + Registers a ``Tag`` catalog row for each tag (see :func:`register_folder_tags`) + and stores the resulting internal keys on the ``folder_model`` row at ``path`` + (creating it if absent). Returns the stored internal keys. Shared by the + map/token/audio folder-tag update endpoints; callers own the transaction. + """ + internals = register_folder_tags(db, raw_tags, category=category) + folder = db.query(folder_model).filter_by(path=path).first() + if folder is not None: + folder.tags = internals + else: + db.add(folder_model(path=path, tags=internals)) + return internals + + +def folder_display_tags(db: Session, internals: Iterable[str]) -> list[str]: + """Resolve a folder's stored internal keys to display strings for API reads. + + Folder JSON holds internal keys; their display casing comes from the catalog + (falling back to the key itself when no ``Tag`` row exists yet). Order is + preserved. + """ + keys = [normalize_internal(i) for i in (internals or []) if normalize_internal(i)] + if not keys: + return [] + catalog = _catalog_display_map(db, set(keys)) + return [catalog.get(k, k) for k in keys] + + +def _promote_category(tag: Tag, category: str) -> None: + """Move a tag to ``shared`` if it's used in a category other than its current + one. A tag stays single-category until it spans a second type (issue #235).""" + if category == SHARED_CATEGORY or tag.category == SHARED_CATEGORY: + return + if tag.category != category: + tag.category = SHARED_CATEGORY + + +def folder_types_for_tag(db: Session, internal: str) -> set[str]: + """The resource types of every media folder whose tags include this key. + + Folder tags live as JSON on the ``*_folders`` tables and never touch a + ``Tag`` row's stored ``category``, so this is how folder usage is factored + into a tag's *effective* category (see :func:`effective_category`). + """ + key = normalize_internal(internal) + if not key: + return set() + types: set[str] = set() + for folder_model, _item_model, rtype in _FOLDER_SOURCES: + found = any( + any(normalize_internal(raw) == key for raw in (f.tags or [])) + for f in db.query(folder_model).all() + ) + if found: + types.add(rtype) + if any( + any(normalize_internal(raw) == key for raw in (f.tags or [])) + for f in db.query(BookFolder).all() + ): + types.add(_BOOK_RESOURCE_TYPE) + return types + + +def effective_category(stored: Optional[str], usage_types: Iterable[str]) -> str: + """Reconcile a tag's stored category with the resource types it's used in. + + ``stored`` is the ``Tag.category`` (or ``None`` for a folder-only tag); + ``usage_types`` is every resource type the tag actually appears on (direct + links and/or folder tags). A tag used across more than one type is ``shared``; + otherwise it's that single type. This makes book (direct) + map-folder usage + resolve to ``shared`` even though folder tags never promote the stored row. + """ + types = {t for t in usage_types if t in RESOURCE_TYPES} + if stored and stored != SHARED_CATEGORY: + types.add(stored) + if stored == SHARED_CATEGORY: + return SHARED_CATEGORY + if len(types) > 1: + return SHARED_CATEGORY + if len(types) == 1: + return next(iter(types)) + return stored or SHARED_CATEGORY + + +def set_resource_tags( + db: Session, resource_type: str, resource_id: str, raw_tags: Iterable[str] +) -> list[dict]: + """Replace all tags on a resource with the given list, returning the new set. + + Input strings are normalised and de-duplicated by internal key. Tags not in + the new list are unlinked from this resource (orphaned tag rows are left for + :func:`prune_orphan_tags` / the tags page to clean up). Order of the returned + list follows first-seen order of the input. + """ + if resource_type not in RESOURCE_TYPES: + raise ValueError(f"Unknown resource_type: {resource_type!r}") + + seen: set[str] = set() + resolved: list[Tag] = [] + for raw in raw_tags or []: + internal = normalize_internal(raw) + if not internal or internal in seen: + continue + seen.add(internal) + # The category this tag is being used in is the resource type; a new tag + # takes it, an existing one may be promoted to shared. + tag = get_or_create_tag(db, raw, category=resource_type) + if tag is not None: + resolved.append(tag) + + # Remove links no longer present. + db.query(ResourceTag).filter( + ResourceTag.resource_type == resource_type, + ResourceTag.resource_id == resource_id, + ).delete(synchronize_session=False) + + for tag in resolved: + db.add( + ResourceTag( + tag_id=tag.id, + resource_type=resource_type, + resource_id=resource_id, + ) + ) + db.flush() + return [tag_dict(t) for t in resolved] + + +def add_resource_tags( + db: Session, resource_type: str, resource_id: str, raw_tags: Iterable[str] +) -> list[dict]: + """Add tags to a resource **without removing** any it already has. + + Used by ``tags.json`` application (the library is read-only, so ``tags.json`` + is an additive input): a new tag creates its catalog row with the entered + casing; an existing tag keeps its display and is linked if not already. Never + unlinks. Returns the tags added this call (skipping ones already present). + """ + if resource_type not in RESOURCE_TYPES: + raise ValueError(f"Unknown resource_type: {resource_type!r}") + + existing = { + r.tag_id + for r in db.query(ResourceTag.tag_id).filter( + ResourceTag.resource_type == resource_type, + ResourceTag.resource_id == resource_id, + ) + } + seen: set[str] = set() + added: list[Tag] = [] + for raw in raw_tags or []: + internal = normalize_internal(raw) + if not internal or internal in seen: + continue + seen.add(internal) + tag = get_or_create_tag(db, raw, category=resource_type) + if tag is None or tag.id in existing: + continue + db.add( + ResourceTag( + tag_id=tag.id, + resource_type=resource_type, + resource_id=resource_id, + ) + ) + existing.add(tag.id) + added.append(tag) + db.flush() + return [tag_dict(t) for t in added] + + +def sync_tags_from_payload( + db: Session, resource_type: str, resource_id: str, payload: dict +) -> Optional[list[dict]]: + """If ``payload`` carries a ``tags`` key, mirror it into the join table. + + Used by resource update handlers that apply a ``model_dump`` payload: they + still ``setattr`` the legacy JSON column, and this keeps the shared-tag tables + in lock-step (dual-write during the parallel-run period). Returns the new tag + list when tags were present, else ``None`` (nothing to do). + """ + if "tags" not in payload: + return None + return set_resource_tags(db, resource_type, resource_id, payload.get("tags") or []) + + +def tags_for_resource(db: Session, resource_type: str, resource_id: str) -> list[dict]: + """All tags on one resource, sorted by display value (case-insensitive).""" + rows = ( + db.query(Tag) + .join(ResourceTag, ResourceTag.tag_id == Tag.id) + .filter( + ResourceTag.resource_type == resource_type, + ResourceTag.resource_id == resource_id, + ) + .all() + ) + rows.sort(key=lambda t: t.display.lower()) + return [tag_dict(t) for t in rows] + + +def tags_for_resources( + db: Session, resource_type: str, resource_ids: list[str] +) -> dict[str, list[dict]]: + """Batch variant of :func:`tags_for_resource`, keyed by resource id. + + Ids with no tags are omitted; callers should default to ``[]``. + """ + if not resource_ids: + return {} + rows = ( + db.query(ResourceTag.resource_id, Tag) + .join(Tag, ResourceTag.tag_id == Tag.id) + .filter( + ResourceTag.resource_type == resource_type, + ResourceTag.resource_id.in_(resource_ids), + ) + .all() + ) + out: dict[str, list[Tag]] = {} + for rid, tag in rows: + out.setdefault(rid, []).append(tag) + result: dict[str, list[dict]] = {} + for rid, tags in out.items(): + tags.sort(key=lambda t: t.display.lower()) + result[rid] = [tag_dict(t) for t in tags] + return result + + +def display_tags_for_resource(db: Session, resource_type: str, resource_id: str) -> list[str]: + """A resource's tags as display strings, sorted case-insensitively. + + This is the read used by API responses (which expose ``tags`` as a plain + string list); it sources tags from the shared-tag tables rather than the + legacy JSON column. + """ + return [t["display"] for t in tags_for_resource(db, resource_type, resource_id)] + + +def display_tags_for_resources( + db: Session, resource_type: str, resource_ids: list[str] +) -> dict[str, list[str]]: + """Batch variant of :func:`display_tags_for_resource`, keyed by resource id. + + Every requested id is present in the result (empty list when untagged), so + callers can index directly without a fallback. + """ + enriched = tags_for_resources(db, resource_type, resource_ids) + return {rid: [t["display"] for t in enriched.get(rid, [])] for rid in resource_ids} + + +def resources_for_tag( + db: Session, internal: str, *, resource_type: Optional[str] = None +) -> list[dict]: + """Every resource carrying the tag with the given internal key. + + Returns ``[{resource_type, resource_id}]``; optionally filtered to one type. + """ + tag = db.query(Tag).filter(Tag.internal == normalize_internal(internal)).first() + if tag is None: + return [] + q = db.query(ResourceTag).filter(ResourceTag.tag_id == tag.id) + if resource_type is not None: + q = q.filter(ResourceTag.resource_type == resource_type) + return [ + {"resource_type": r.resource_type, "resource_id": r.resource_id} + for r in q.all() + ] + + +def tags_in_use(db: Session, resource_type: Optional[str] = None) -> list[dict]: + """Tags that are attached to at least one resource, with a usage count. + + Scoped to ``resource_type`` when given (issue #235.3: only show tags used on + the current page). Sorted by display value. Each entry adds ``count``. + """ + q = ( + db.query(Tag, func.count(ResourceTag.id).label("count")) + .join(ResourceTag, ResourceTag.tag_id == Tag.id) + ) + if resource_type is not None: + q = q.filter(ResourceTag.resource_type == resource_type) + q = q.group_by(Tag.id) + rows = q.all() + rows.sort(key=lambda row: row[0].display.lower()) + return [{**tag_dict(tag), "count": count} for tag, count in rows] + + +def _ancestor_folder_paths(relative_path: str) -> set[str]: + """Every folder path (collection-relative) an item lives under. + + ``relative_path`` is ``///``; the item belongs to + folders ``a`` and ``a/b`` (folder table paths are collection-relative). This + mirrors the ``//`` containment used elsewhere but computed in Python so + the whole set can be resolved from one item scan (no per-folder LIKE query). + """ + parts = (relative_path or "").replace("\\", "/").split("/") + segs = parts[1:-1] # drop collection prefix and filename + return {"/".join(segs[: i + 1]) for i in range(len(segs))} + + +def _catalog_display_map(db: Session, internals: set[str]) -> dict[str, str]: + """Map internal keys → the ``Tag`` catalog's display casing, for keys present. + + Folder tags store internal keys; their display comes from the catalog so a + rename on the tags page (which updates the ``Tag`` row) is reflected in + folder-derived listings and a ``tags.json`` rescan can't revert it. + """ + if not internals: + return {} + return { + t.internal: t.display + for t in db.query(Tag).filter(Tag.internal.in_(internals)).all() + } + + +def folder_tags_in_use( + db: Session, resource_type: Optional[str] = None +) -> dict[str, dict]: + """Folder-derived tags keyed by internal, with display + a de-duplicated set + of the item refs they cover. + + Folder tags live as plain JSON on the ``*_folders`` tables and are not part of + the shared-tag tables, but the media galleries treat them as tags on the items + inside the folder. This resolves them the same way so the tags view can list + them and show their items. Scoped to ``resource_type`` when given. + + Returns ``{internal: {"display": str, "refs": [{resource_type, resource_id}]}}``. + The display is the first-seen casing across folders. Resolves everything from + two bulk queries per type (all folders + all item id/paths) rather than a LIKE + per folder, so the tags listing stays fast as libraries grow. + """ + out: dict[str, dict] = {} + for folder_model, item_model, rtype in _FOLDER_SOURCES: + if resource_type is not None and rtype != resource_type: + continue + + # folder path -> its tag list (skip untagged folders). + folder_tags: dict[str, list] = { + f.path: f.tags for f in db.query(folder_model).all() if f.tags + } + if not folder_tags: + continue + + # For each item, attach its folders' tags via the item's ancestor paths. + for item_id, rel in db.query(item_model.id, item_model.relative_path).all(): + ancestors = _ancestor_folder_paths(rel) + if not ancestors: + continue + for path in ancestors & folder_tags.keys(): + for raw in folder_tags[path]: + internal = normalize_internal(raw) + if not internal: + continue + entry = out.setdefault( + internal, {"display": default_display(raw) or internal, "refs": {}} + ) + entry["refs"][(rtype, item_id)] = { + "resource_type": rtype, + "resource_id": item_id, + } + + # Book subcategory folders (distinct addressing — see _BOOK_RESOURCE_TYPE). + if resource_type is None or resource_type == _BOOK_RESOURCE_TYPE: + book_folder_tags = {f.path: f.tags for f in db.query(BookFolder).all() if f.tags} + if book_folder_tags: + books = db.query( + Book.id, Book.game_system_id, Book.category, Book.relative_path + ).all() + for book_id, sys_id, category, rel in books: + ancestors = _book_folder_ancestor_paths(sys_id or "", category or "", rel) + for path in ancestors & book_folder_tags.keys(): + for raw in book_folder_tags[path]: + internal = normalize_internal(raw) + if not internal: + continue + entry = out.setdefault( + internal, {"display": default_display(raw) or internal, "refs": {}} + ) + entry["refs"][(_BOOK_RESOURCE_TYPE, book_id)] = { + "resource_type": _BOOK_RESOURCE_TYPE, + "resource_id": book_id, + } + + # The catalog is authoritative for display casing (a rename updates the Tag + # row); fall back to the JSON-derived default for keys with no Tag row yet. + catalog = _catalog_display_map(db, set(out.keys())) + return { + internal: { + "display": catalog.get(internal, v["display"]), + "refs": list(v["refs"].values()), + } + for internal, v in out.items() + } + + +def folders_for_tag( + db: Session, internal: str, *, resource_type: Optional[str] = None +) -> list[dict]: + """Folders carrying the given tag, each with the item refs they contain. + + Used by the tags view to show a folder tag as a folder group (like the media + pages) listing everything inside the folder — even items that don't carry the + tag themselves. Returns + ``[{resource_type, path, items: [{resource_type, resource_id}]}]``, sorted by + (resource_type, path). + """ + key = normalize_internal(internal) + result: list[dict] = [] + for folder_model, item_model, rtype in _FOLDER_SOURCES: + if resource_type is not None and rtype != resource_type: + continue + # Folders whose tags include this key. + paths = [ + f.path + for f in db.query(folder_model).all() + if any(normalize_internal(raw) == key for raw in (f.tags or [])) + ] + if not paths: + continue + path_set = set(paths) + # Bucket items into the matching folders via their ancestor paths. + buckets: dict[str, list] = {p: [] for p in paths} + for item_id, rel in db.query(item_model.id, item_model.relative_path).all(): + for p in _ancestor_folder_paths(rel) & path_set: + buckets[p].append({"resource_type": rtype, "resource_id": item_id}) + for p in paths: + result.append({"resource_type": rtype, "path": p, "items": buckets[p]}) + + # Book subcategory folders (distinct addressing — see _BOOK_RESOURCE_TYPE). + if resource_type is None or resource_type == _BOOK_RESOURCE_TYPE: + book_paths = [ + f.path + for f in db.query(BookFolder).all() + if any(normalize_internal(raw) == key for raw in (f.tags or [])) + ] + if book_paths: + path_set = set(book_paths) + buckets = {p: [] for p in book_paths} + books = db.query( + Book.id, Book.game_system_id, Book.category, Book.relative_path + ).all() + for book_id, sys_id, category, rel in books: + ancestors = _book_folder_ancestor_paths(sys_id or "", category or "", rel) + for p in ancestors & path_set: + buckets[p].append( + {"resource_type": _BOOK_RESOURCE_TYPE, "resource_id": book_id} + ) + for p in book_paths: + # Show only the subfolder hierarchy (drop the system_id/category + # prefix), so the folder title reads like the media pages. + display_path = "/".join(p.split("/")[2:]) or _book_folder_display(p) + result.append( + {"resource_type": _BOOK_RESOURCE_TYPE, "path": display_path, "items": buckets[p]} + ) + result.sort(key=lambda f: (f["resource_type"], f["path"].lower())) + return result + + +def remove_tag_from_folders(db: Session, internal: str) -> int: + """Strip the tag with the given internal key from every media folder's JSON + ``tags`` list. Returns the number of folder rows changed. + + A rescan may reapply the tag from ``tags.json``; that's expected. Callers own + the transaction (no commit here). + """ + key = normalize_internal(internal) + changed = 0 + folder_models = [m for m, _i, _r in _FOLDER_SOURCES] + [BookFolder] + for folder_model in folder_models: + for folder in db.query(folder_model).all(): + tags = folder.tags or [] + kept = [raw for raw in tags if normalize_internal(raw) != key] + if len(kept) != len(tags): + folder.tags = kept + changed += 1 + return changed + + +def tags_meta_for_internals(db: Session, internals: list[str]) -> dict[str, dict]: + """Resolve tag internal keys to ``{internal, display, count}`` metadata. + + Used to enrich favorited tags. ``count`` is the number of distinct items the + tag covers (shared links + folder-derived), matching the tags-view total. + A favorited tag that no longer exists anywhere is omitted from the result. + """ + keys = {normalize_internal(i) for i in internals if normalize_internal(i)} + if not keys: + return {} + + # Shared tags matching the keys, with their direct link counts. + shared = { + t.internal: {"internal": t.internal, "display": t.display, "id": t.id} + for t in db.query(Tag).filter(Tag.internal.in_(keys)).all() + } + link_counts: dict[str, int] = {} + if shared: + ids = [v["id"] for v in shared.values()] + rows = ( + db.query(ResourceTag.tag_id, func.count(func.distinct(ResourceTag.resource_id))) + .filter(ResourceTag.tag_id.in_(ids)) + .group_by(ResourceTag.tag_id) + .all() + ) + id_to_internal = {v["id"]: k for k, v in shared.items()} + for tag_id, cnt in rows: + link_counts[id_to_internal[tag_id]] = cnt + + # Folder-derived coverage for the same keys. + folder = folder_tags_in_use(db) + + out: dict[str, dict] = {} + for key in keys: + display = None + count = link_counts.get(key, 0) + if key in shared: + display = shared[key]["display"] + if key in folder: + display = display or folder[key]["display"] + count += len(folder[key]["refs"]) # upper bound; folder ∪ shared rarely overlap + if display is not None: + out[key] = {"internal": key, "display": display, "count": count} + return out + + +def rename_tag(db: Session, internal: str, new_display: str) -> Optional[Tag]: + """Rename a tag's display value, re-keying its ``internal`` when the new + display normalizes to a different key (e.g. fixing a typo ``freinds`` → + ``friends`` so search-by-internal finds it). + + If another tag already owns the new internal key, this tag is merged into it + (links re-pointed, folder JSON entries rewritten, source row deleted) and the + surviving tag is returned. Otherwise the same row is updated in place. + + A **folder-only** tag (no ``Tag`` row yet — it exists only in folder JSON) is + materialised into a catalog row first, so its new display is stored in the DB + and a ``tags.json`` rescan can't revert it (the library is read-only). Returns + ``None`` only if ``internal`` resolves to nothing at all. Callers own the + transaction. + """ + key = normalize_internal(internal) + src = db.query(Tag).filter(Tag.internal == key).first() + if src is None: + # Folder-only tag: create its catalog row so the rename can persist. + folder_types = folder_types_for_tag(db, key) + if not folder_types: + return None + category = next(iter(folder_types)) if len(folder_types) == 1 else SHARED_CATEGORY + src = get_or_create_tag(db, key, category=category) + if src is None: + return None + display = default_display(new_display) + if not display: + return src + new_internal = normalize_internal(display) + + # Same key → just update the display casing (the common rename). + if new_internal == src.internal: + src.display = display + db.flush() + return src + + # Key changes: fold any media-folder JSON entries onto the new key so folder + # tags follow the rename too. + _rekey_folder_tags(db, src.internal, display) + + existing = db.query(Tag).filter(Tag.internal == new_internal).first() + if existing is None or existing.id == src.id: + src.internal = new_internal + src.display = display + db.flush() + return src + + # Collision: merge src → existing (re-point links, drop dupes, delete src). + existing.display = display + _promote_category(existing, src.category) + dst_links = { + (r.resource_type, r.resource_id) + for r in db.query(ResourceTag).filter(ResourceTag.tag_id == existing.id) + } + for link in db.query(ResourceTag).filter(ResourceTag.tag_id == src.id).all(): + if (link.resource_type, link.resource_id) in dst_links: + db.delete(link) + else: + link.tag_id = existing.id + db.query(Tag).filter(Tag.id == src.id).delete(synchronize_session=False) + db.flush() + return existing + + +def _rekey_folder_tags(db: Session, old_internal: str, new_display: str) -> None: + """Rewrite media-folder JSON tag entries matching ``old_internal`` to the new + tag's internal key (folders store internal keys; display lives in the + catalog), de-duplicating by key within each folder.""" + old_key = normalize_internal(old_internal) + new_key = normalize_internal(new_display) + folder_models = [m for m, _i, _r in _FOLDER_SOURCES] + [BookFolder] + for folder_model in folder_models: + for folder in db.query(folder_model).all(): + tags = folder.tags or [] + if not any(normalize_internal(raw) == old_key for raw in tags): + continue + rebuilt: list[str] = [] + seen: set[str] = set() + for raw in tags: + key = new_key if normalize_internal(raw) == old_key else normalize_internal(raw) + if not key or key in seen: + continue + seen.add(key) + rebuilt.append(key) + folder.tags = rebuilt + + +def prune_orphan_tags(db: Session) -> int: + """Delete tags with no remaining resource links. Returns the count removed.""" + orphans = ( + db.query(Tag.id) + .outerjoin(ResourceTag, ResourceTag.tag_id == Tag.id) + .filter(ResourceTag.id.is_(None)) + .all() + ) + ids = [row[0] for row in orphans] + if ids: + db.query(Tag).filter(Tag.id.in_(ids)).delete(synchronize_session=False) + return len(ids) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 01ae30f..f27d212 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -27,6 +27,20 @@ Audio, Campaign, ) +from backend.services import tag_service # noqa: E402 + + +def _sync_tags(resource_type: str, resource_id: str, tags) -> None: + """Mirror a factory's ``tags`` into the shared-tag tables, matching the app's + write path so join-table reads see them (tags now come from that table).""" + if not tags: + return + db = SessionLocal() + try: + tag_service.set_resource_tags(db, resource_type, resource_id, tags) + db.commit() + finally: + db.close() # --------------------------------------------------------------------------- @@ -182,12 +196,14 @@ def make_game_system(**kwargs) -> GameSystem: description="", ) defaults.update(kwargs) + tags = defaults.pop("tags", None) # shared tags live in the join table now db = SessionLocal() system = GameSystem(**defaults) db.add(system) db.commit() db.refresh(system) db.close() + _sync_tags("system", system.id, tags) return system @@ -202,12 +218,14 @@ def make_book(system_id: str, **kwargs) -> Book: category="core", ) defaults.update(kwargs) + tags = defaults.pop("tags", None) db = SessionLocal() book = Book(**defaults) db.add(book) db.commit() db.refresh(book) db.close() + _sync_tags("book", book.id, tags) return book @@ -219,12 +237,14 @@ def make_map(**kwargs) -> GenericMap: relative_path=f"map-{uid}.png", ) defaults.update(kwargs) + tags = defaults.pop("tags", None) db = SessionLocal() m = GenericMap(**defaults) db.add(m) db.commit() db.refresh(m) db.close() + _sync_tags("map", m.id, tags) return m @@ -236,12 +256,14 @@ def make_token(**kwargs) -> Token: relative_path=f"token-{uid}.png", ) defaults.update(kwargs) + tags = defaults.pop("tags", None) db = SessionLocal() t = Token(**defaults) db.add(t) db.commit() db.refresh(t) db.close() + _sync_tags("token", t.id, tags) return t @@ -255,12 +277,14 @@ def make_audio(**kwargs) -> Audio: title=f"Track {uid}", ) defaults.update(kwargs) + tags = defaults.pop("tags", None) db = SessionLocal() a = Audio(**defaults) db.add(a) db.commit() db.refresh(a) db.close() + _sync_tags("audio", a.id, tags) return a diff --git a/backend/tests/test_audio.py b/backend/tests/test_audio.py index 07648d3..69ae7a3 100644 --- a/backend/tests/test_audio.py +++ b/backend/tests/test_audio.py @@ -2,6 +2,7 @@ import os import struct import tempfile +import uuid import wave import pytest @@ -89,16 +90,20 @@ def test_gm_can_update_audio(self, client, gm_headers, audio_entry): assert resp.status_code == 200 assert resp.json() == {"status": "ok"} - def test_tags_are_lowercased_on_update(self, client, gm_headers): + def test_tags_keep_display_casing_on_update(self, client, gm_headers): + # Shared tags preserve the entered casing; matching is case-insensitive. + # Returned tags are sorted by display value (case-insensitive). Unique tag + # names avoid reusing a display value set by another test. + uid = uuid.uuid4().hex[:6] a = make_audio() resp = client.patch( f"/api/audio/{a.id}", - json={"tags": ["Ambient", "TAVERN", "loop"]}, + json={"tags": [f"Alpha{uid}", f"BRAVO{uid}", f"charlie{uid}"]}, headers=gm_headers, ) assert resp.status_code == 200 detail = client.get(f"/api/audio/{a.id}", headers=gm_headers).json() - assert detail["tags"] == ["ambient", "tavern", "loop"] + assert detail["tags"] == [f"Alpha{uid}", f"BRAVO{uid}", f"charlie{uid}"] def test_duplicate_tags_deduplicated_on_update(self, client, gm_headers): a = make_audio() diff --git a/backend/tests/test_campaign_wiki.py b/backend/tests/test_campaign_wiki.py index e4948bb..3a59814 100644 --- a/backend/tests/test_campaign_wiki.py +++ b/backend/tests/test_campaign_wiki.py @@ -64,6 +64,15 @@ def test_duplicate_title_gets_unique_slug(self, client, gm_headers, gm_campaign) assert a["slug"] == "dragon" assert b["slug"] == "dragon-2" + def test_unicode_title_keeps_letters_in_slug(self, client, gm_headers, gm_campaign): + # German special characters (and other Unicode letters) must survive + # slugification so [[links]] resolve; the frontend slugify matches this + # (issue #252). + page = _create(client, gm_headers, gm_campaign["id"], title="Breitfuß").json() + assert page["slug"] == "breitfuß" + page2 = _create(client, gm_headers, gm_campaign["id"], title="Zürich Straße").json() + assert page2["slug"] == "zürich-straße" + def test_update_page(self, client, gm_headers, gm_campaign): page = _create(client, gm_headers, gm_campaign["id"], title="Notes").json() resp = client.patch( diff --git a/backend/tests/test_db_migrations.py b/backend/tests/test_db_migrations.py index 6a47ab1..ac01d87 100644 --- a/backend/tests/test_db_migrations.py +++ b/backend/tests/test_db_migrations.py @@ -61,20 +61,23 @@ def test_init_db_is_idempotent(self): class TestNormalizeTags: - def test_normalizes_audio_table_tags(self): + # Item tables no longer carry a JSON ``tags`` column (issue #235 moved item + # tags to the shared-tag tables), so normalization now only runs on the + # folder tables — these tests exercise that via ``audio_folders``. + + def test_normalizes_folder_table_tags(self): path = _fresh_db() engine = create_engine(f"sqlite:///{path}") with engine.connect() as conn: conn.execute( text( - "INSERT INTO audio (id, filename, filepath, relative_path, tags) " - "VALUES ('a1', 'x.mp3', '/x.mp3', 'audio/x.mp3', :tags)" + "INSERT INTO audio_folders (id, path, tags) VALUES ('n1', 'Sfx', :tags)" ), {"tags": json.dumps(["Ambient", "AMBIENT", " Tavern "])}, ) conn.commit() _normalize_tags_in_db(conn) - raw = conn.execute(text("SELECT tags FROM audio WHERE id='a1'")).scalar() + raw = conn.execute(text("SELECT tags FROM audio_folders WHERE id='n1'")).scalar() assert json.loads(raw) == ["ambient", "tavern"] def test_normalizes_audio_folder_tags(self): @@ -99,15 +102,12 @@ def test_skips_non_list_tags_without_raising(self): engine = create_engine(f"sqlite:///{path}") with engine.connect() as conn: conn.execute( - text( - "INSERT INTO audio (id, filename, filepath, relative_path, tags) " - "VALUES ('a2', 'y.mp3', '/y.mp3', 'audio/y.mp3', :tags)" - ), + text("INSERT INTO audio_folders (id, path, tags) VALUES ('n2', 'A2', :tags)"), {"tags": json.dumps({"not": "a list"})}, ) conn.commit() _normalize_tags_in_db(conn) # must not raise - raw = conn.execute(text("SELECT tags FROM audio WHERE id='a2'")).scalar() + raw = conn.execute(text("SELECT tags FROM audio_folders WHERE id='n2'")).scalar() assert json.loads(raw) == {"not": "a list"} def test_already_normalized_tags_unchanged(self): @@ -115,15 +115,12 @@ def test_already_normalized_tags_unchanged(self): engine = create_engine(f"sqlite:///{path}") with engine.connect() as conn: conn.execute( - text( - "INSERT INTO audio (id, filename, filepath, relative_path, tags) " - "VALUES ('a3', 'z.mp3', '/z.mp3', 'audio/z.mp3', :tags)" - ), + text("INSERT INTO audio_folders (id, path, tags) VALUES ('n3', 'A3', :tags)"), {"tags": json.dumps(["already", "clean"])}, ) conn.commit() _normalize_tags_in_db(conn) - raw = conn.execute(text("SELECT tags FROM audio WHERE id='a3'")).scalar() + raw = conn.execute(text("SELECT tags FROM audio_folders WHERE id='n3'")).scalar() assert json.loads(raw) == ["already", "clean"] def test_malformed_json_tags_are_skipped(self, caplog): @@ -134,15 +131,12 @@ def test_malformed_json_tags_are_skipped(self, caplog): engine = create_engine(f"sqlite:///{path}") with engine.connect() as conn: conn.execute( - text( - "INSERT INTO audio (id, filename, filepath, relative_path, tags) " - "VALUES ('a4', 'b.mp3', '/b.mp3', 'audio/b.mp3', 'not-json{')" - ) + text("INSERT INTO audio_folders (id, path, tags) VALUES ('n4', 'A4', 'not-json{')") ) conn.commit() with caplog.at_level("WARNING", logger="grimoire.db"): _normalize_tags_in_db(conn) # must not raise - raw = conn.execute(text("SELECT tags FROM audio WHERE id='a4'")).scalar() + raw = conn.execute(text("SELECT tags FROM audio_folders WHERE id='n4'")).scalar() assert raw == "not-json{" assert any( "skipping tag normalization" in r.message.lower() for r in caplog.records @@ -432,3 +426,80 @@ def test_upgrade_is_reentrant_after_partial_apply(self): init_db(path) # must be a clean no-op, not "duplicate column" error assert _stamped_revision(path) == _alembic_head(path) + + +class TestExpandMetadataMigration: + """Migration 0004: new columns, lookup seeds, and legacy backfill (#202).""" + + def test_new_columns_present(self): + path = _fresh_db() + engine = create_engine(f"sqlite:///{path}") + insp = inspect(engine) + sys_cols = {c["name"] for c in insp.get_columns("game_systems")} + book_cols = {c["name"] for c in insp.get_columns("books")} + assert { + "genres", + "dice_materials", + "system_family", + "license", + "year", + "urls", + "character_builder_urls", + "is_one_page", + } <= sys_cols + assert {"artists", "genres", "isbn", "version", "language", "month", "day", "urls"} <= book_cols + + def test_lookup_tables_seeded(self): + path = _fresh_db() + engine = create_engine(f"sqlite:///{path}") + with engine.connect() as conn: + genre_count = conn.execute(text("SELECT count(*) FROM genres")).scalar() + fam_count = conn.execute(text("SELECT count(*) FROM system_families")).scalar() + cyber = conn.execute( + text( + "SELECT p.name FROM genres g JOIN genres p ON g.parent_id=p.id " + "WHERE g.name='Cyberpunk'" + ) + ).scalar() + assert genre_count > 0 + assert fam_count > 0 + assert cyber == "Science Fiction" + + def test_legacy_backfill(self): + """genre → genres, character_builder_url → list, publisher_url → book urls.""" + path = os.path.join(tempfile.mkdtemp(), "legacy.db") + engine = create_engine(f"sqlite:///{path}") + # Migrate up to the pre-0004 revision, insert legacy rows, then finish. + with engine.connect() as conn: + from alembic import command + + cfg = _alembic_config(conn) + command.upgrade(cfg, "b2e5d3f0c8a1") + conn.commit() + with engine.connect() as conn: + conn.execute( + text( + "INSERT INTO game_systems (id, name, slug, genre, character_builder_url) " + "VALUES ('s1','S','s','Fantasy','http://b')" + ) + ) + conn.execute( + text( + "INSERT INTO books (id, title, filename, filepath, relative_path, publisher_url) " + "VALUES ('b1','B','b.pdf','/b.pdf','b.pdf','http://p')" + ) + ) + conn.commit() + engine.dispose() + + init_db(path) # runs 0004 including backfill + + engine = create_engine(f"sqlite:///{path}") + with engine.connect() as conn: + g = conn.execute( + text("SELECT genres, character_builder_urls FROM game_systems WHERE id='s1'") + ).fetchone() + b = conn.execute(text("SELECT urls FROM books WHERE id='b1'")).fetchone() + assert json.loads(g[0]) == ["Fantasy"] + assert json.loads(g[1])[0]["url"] == "http://b" + assert json.loads(b[0])[0]["url"] == "http://p" diff --git a/backend/tests/test_downloads.py b/backend/tests/test_downloads.py index f07f9cc..d032001 100644 --- a/backend/tests/test_downloads.py +++ b/backend/tests/test_downloads.py @@ -348,6 +348,21 @@ def core_subfolder_book(adventure_system): ) +@pytest.fixture(scope="module") +def deep_nested_book(adventure_system): + """A book nested 2 levels deep: core/monsters/spelljammer/book.pdf (issue #189).""" + uid = uuid.uuid4().hex[:6] + path = _real_file(f"spelljammer_{uid}.pdf", b"%PDF-1.4 spelljammer") + return make_book( + system_id=adventure_system.id, + title=f"Spelljammer Monsters {uid}", + filename=os.path.basename(path), + filepath=path, + relative_path=f"books/AdventureSystem/core/monsters/spelljammer/{os.path.basename(path)}", + category="core", + ) + + class TestDownloadValidation: def test_unknown_type_returns_400(self, client, admin_headers): resp = client.get( @@ -750,6 +765,41 @@ def test_nonexistent_system_returns_404(self, client, admin_headers): ) assert resp.status_code == 404 + def test_nested_folder_path_scopes_to_deep_folder( + self, client, admin_headers, adventure_system, deep_nested_book, core_subfolder_book + ): + """A '/'-joined nested folder path matches books 2+ levels deep (issue #189).""" + content = _get_archive( + client, + admin_headers, + { + "type": "book_folder", + "id": adventure_system.id, + "folder": "monsters/spelljammer", + }, + ) + names = _zip_names(content) + assert any(deep_nested_book.filename in n for n in names) + # The shallower monsters/ sibling must not be pulled in. + assert core_subfolder_book.filename not in names + + def test_parent_folder_includes_nested_descendants( + self, client, admin_headers, adventure_system, deep_nested_book, core_subfolder_book + ): + """Downloading a parent folder includes books nested beneath it, and their + archive paths preserve the sub-hierarchy below the requested folder.""" + content = _get_archive( + client, + admin_headers, + {"type": "book_folder", "id": adventure_system.id, "folder": "monsters"}, + ) + names = _zip_names(content) + assert any(core_subfolder_book.filename in n for n in names) + # Nested descendant is present and keeps its "spelljammer/" prefix. + assert any( + n.endswith(f"spelljammer/{deep_nested_book.filename}") for n in names + ) + # --------------------------------------------------------------------------- # Format variants — tar, tar.gz, tar.bz2 diff --git a/backend/tests/test_export.py b/backend/tests/test_export.py index d17d345..b37fbfb 100644 --- a/backend/tests/test_export.py +++ b/backend/tests/test_export.py @@ -192,7 +192,7 @@ def test_system_tags_included(self, client, admin_headers, export_system): systems = data["library"]["systems"] match = next((s for s in systems if s["slug"] == export_system.slug), None) assert match is not None - assert match["tags"] == export_system.tags + assert sorted(match["tags"]) == ["fantasy", "official"] def test_system_entry_shape(self, client, admin_headers, export_system): data = client.get("/api/export/tags", headers=admin_headers).json() @@ -208,7 +208,7 @@ def test_book_tags_included(self, client, admin_headers, export_book): books = data["library"]["books"] match = next((b for b in books if b["id"] == export_book.id), None) assert match is not None - assert match["tags"] == export_book.tags + assert sorted(match["tags"]) == ["core", "phb"] def test_book_entry_shape(self, client, admin_headers, export_book): data = client.get("/api/export/tags", headers=admin_headers).json() @@ -230,7 +230,7 @@ def test_map_tags_included(self, client, admin_headers, export_map): maps = data["maps"]["items"] match = next((m for m in maps if m["id"] == export_map.id), None) assert match is not None - assert match["tags"] == export_map.tags + assert sorted(match["tags"]) == ["dungeon", "outdoor"] def test_map_entry_shape(self, client, admin_headers, export_map): data = client.get("/api/export/tags", headers=admin_headers).json() @@ -257,7 +257,7 @@ def test_token_tags_included(self, client, admin_headers, export_token): tokens = data["tokens"]["items"] match = next((t for t in tokens if t["id"] == export_token.id), None) assert match is not None - assert match["tags"] == export_token.tags + assert sorted(match["tags"]) == ["cr5", "monster"] def test_token_entry_shape(self, client, admin_headers, export_token): data = client.get("/api/export/tags", headers=admin_headers).json() diff --git a/backend/tests/test_favorites.py b/backend/tests/test_favorites.py index b29b04b..54e4044 100644 --- a/backend/tests/test_favorites.py +++ b/backend/tests/test_favorites.py @@ -218,3 +218,49 @@ def test_remove_nonexistent_is_silent(self, client, admin_headers): def test_unauthenticated_denied(self, client, fav_book): resp = client.delete(f"/api/favorites/book/{fav_book.id}") assert resp.status_code == 401 + + +class TestTagFavorites: + """Tags can be favorited by their internal key (issue #235 follow-up).""" + + def test_add_tag_favorite_and_enrich(self, client, admin_headers): + import uuid as _uuid + + from backend.tests.conftest import make_map + + label = f"FavTag{_uuid.uuid4().hex[:6]}" + m = make_map() + client.patch(f"/api/maps/{m.id}", json={"tags": [label]}, headers=admin_headers) + # Favorite the tag by its internal key. + resp = client.post( + "/api/favorites", + json={"item_type": "tag", "item_id": label.lower()}, + headers=admin_headers, + ) + assert resp.status_code == 201 + # It enriches to {internal, display, count}. + items = client.get("/api/favorites", headers=admin_headers).json()["items"] + match = next( + (i for i in items if i["item_type"] == "tag" and i["item_id"] == label.lower()), + None, + ) + assert match is not None + assert match["display"] == label + assert match["count"] >= 1 + + def test_tag_list_marks_favorite(self, client, admin_headers): + import uuid as _uuid + + from backend.tests.conftest import make_map + + label = f"HeartTag{_uuid.uuid4().hex[:6]}" + m = make_map() + client.patch(f"/api/maps/{m.id}", json={"tags": [label]}, headers=admin_headers) + client.post( + "/api/favorites", + json={"item_type": "tag", "item_id": label.lower()}, + headers=admin_headers, + ) + listing = client.get("/api/tags?in_use_by=map", headers=admin_headers).json()["tags"] + row = next(t for t in listing if t["internal"] == label.lower()) + assert row["is_favorite"] is True diff --git a/backend/tests/test_indexer_category.py b/backend/tests/test_indexer_category.py index 92135ab..3f91532 100644 --- a/backend/tests/test_indexer_category.py +++ b/backend/tests/test_indexer_category.py @@ -1,5 +1,13 @@ """Tests for guess_category(), agnostic_category(), and is_system_agnostic_folder() in the library indexer.""" -from backend.indexer import guess_category, agnostic_category, is_system_agnostic_folder +from backend.indexer import ( + agnostic_category, + guess_category, + is_one_page_folder, + is_special_collection_folder, + is_system_agnostic_folder, + slugify, + strip_sort_prefix, +) class TestKnownCategories: @@ -214,6 +222,85 @@ def test_empty_string_not_agnostic(self): assert is_system_agnostic_folder("") is False +class TestOnePageFolder: + """Tests for is_one_page_folder() and is_special_collection_folder() (#202).""" + + def test_one_page_rpgs(self): + assert is_one_page_folder("One-Page RPGs") is True + + def test_single_page_rpgs_alias(self): + assert is_one_page_folder("Single-Page RPGs") is True + + def test_one_shot_rpgs_alias(self): + assert is_one_page_folder("One-Shot RPGs") is True + + def test_case_insensitive(self): + assert is_one_page_folder("one-page-rpgs") is True + + def test_normal_folder_not_one_page(self): + assert is_one_page_folder("Dungeons and Dragons 5e") is False + + def test_agnostic_not_one_page(self): + assert is_one_page_folder("System Agnostic") is False + + def test_special_includes_agnostic(self): + assert is_special_collection_folder("System Agnostic") is True + + def test_special_includes_one_page(self): + assert is_special_collection_folder("One-Page RPGs") is True + + def test_special_excludes_normal(self): + assert is_special_collection_folder("Pathfinder 2e") is False + + def test_one_page_uses_subfolder_category(self): + # One-page collections share the agnostic category resolver. + assert agnostic_category("books/One-Page RPGs/Honey Heist/hh.pdf") == "honey-heist" + + +class TestStripSortPrefix: + """Leading !$% sort-order prefixes are stripped from system folder names.""" + + def test_strips_single_bang(self): + assert strip_sort_prefix("!system-agnostic") == "system-agnostic" + + def test_strips_double_bang(self): + assert strip_sort_prefix("!!Dungeons & Dragons") == "Dungeons & Dragons" + + def test_strips_mixed_prefix_chars(self): + assert strip_sort_prefix("!$%Pathfinder 2e") == "Pathfinder 2e" + + def test_stops_at_first_non_prefix_char(self): + # Only the leading run is removed; internal specials are preserved. + assert strip_sort_prefix("!!D&D $ Extras") == "D&D $ Extras" + + def test_no_prefix_is_unchanged(self): + assert strip_sort_prefix("Call of Cthulhu") == "Call of Cthulhu" + + def test_trims_surrounding_whitespace(self): + assert strip_sort_prefix("!! Dungeons & Dragons ") == "Dungeons & Dragons" + + def test_empty_string(self): + assert strip_sort_prefix("") == "" + + def test_all_prefix_chars_collapse_to_empty(self): + assert strip_sort_prefix("!!!") == "" + + def test_does_not_strip_other_specials(self): + # A hash isn't in the recognised set, so nothing is stripped. + assert strip_sort_prefix("#Homebrew") == "#Homebrew" + + def test_prefixed_agnostic_folder_still_detected(self): + # Detection goes through slugify, which already drops "!", so prefixed + # special-collection folders are recognised regardless. + assert is_system_agnostic_folder("!system-agnostic") is True + + def test_prefixed_one_page_folder_still_detected(self): + assert is_one_page_folder("!!one-page-rpgs") is True + + def test_stripped_name_slugs_cleanly(self): + assert slugify(strip_sort_prefix("!!Dungeons & Dragons")) == "dungeons-dragons" + + class TestAgnosticCategory: """Tests for agnostic_category() — the category resolver for system-agnostic books.""" diff --git a/backend/tests/test_indexer_opf.py b/backend/tests/test_indexer_opf.py index 74b522d..64f3977 100644 --- a/backend/tests/test_indexer_opf.py +++ b/backend/tests/test_indexer_opf.py @@ -10,6 +10,7 @@ from backend.config import SessionLocal from backend.indexer import parse_opf_metadata, scan_library from backend.models import Book +from backend.services import tag_service # --------------------------------------------------------------------------- @@ -327,7 +328,13 @@ def test_metadata_opf_applied_when_no_stem_opf(self): assert book.authors == ["Wizards of the Coast"] assert book.publisher == "Wizards of the Coast" assert book.year == 2014 - assert "tabletop rpg" in book.tags + # OPF subjects become shared tags; match on the internal (lowercased) key. + db = SessionLocal() + try: + internals = {t["internal"] for t in tag_service.tags_for_resource(db, "book", book.id)} + finally: + db.close() + assert "tabletop rpg" in internals def test_stem_opf_takes_priority_over_metadata_opf(self): stem_opf = dedent("""\ diff --git a/backend/tests/test_indexer_sort_prefix.py b/backend/tests/test_indexer_sort_prefix.py new file mode 100644 index 0000000..376581b --- /dev/null +++ b/backend/tests/test_indexer_sort_prefix.py @@ -0,0 +1,99 @@ +"""Tests for leading sort-order prefix (!$%) stripping in the library scanner. + +People prepend characters like "!", "$", or "%" to system folders so their file +browser sorts them first. The scanner strips that leading run when deriving the +system name/slug, while keeping the rest of the name (including internal specials) +verbatim. +""" +import tempfile +from pathlib import Path + +from backend.config import SessionLocal +from backend.models import GameSystem +from backend.indexer import scan_library + + +def _mk_lib(): + tmp = tempfile.mkdtemp() + lib = Path(tmp) / "library" + lib.mkdir() + return tmp, lib + + +def _books_dir(lib: Path, system_folder: str) -> Path: + d = lib / "books" / system_folder + d.mkdir(parents=True, exist_ok=True) + return d + + +def _touch_pdf(folder: Path, name: str = "book.pdf") -> Path: + p = folder / name + p.write_bytes(b"%PDF-1.4") + return p + + +def _get_system_by_slug(slug: str): + db = SessionLocal() + try: + return db.query(GameSystem).filter_by(slug=slug).first() + finally: + db.close() + + +def _scan(lib: Path, tmp: str): + db = SessionLocal() + try: + scan_library(str(lib), tmp, db) + finally: + db.close() + + +class TestSortPrefixStripping: + def test_double_bang_prefix_stripped_from_name(self): + tmp, lib = _mk_lib() + _touch_pdf(_books_dir(lib, "!!Dungeons & Dragons")) + _scan(lib, tmp) + + system = _get_system_by_slug("dungeons-dragons") + assert system is not None + assert system.name == "Dungeons & Dragons" + + def test_single_bang_agnostic_folder_still_agnostic(self): + tmp, lib = _mk_lib() + _touch_pdf(_books_dir(lib, "!system-agnostic")) + _scan(lib, tmp) + + system = _get_system_by_slug("system-agnostic") + assert system is not None + assert system.name == "system-agnostic" + assert system.is_system_agnostic is True + + def test_mixed_prefix_chars_stripped(self): + tmp, lib = _mk_lib() + _touch_pdf(_books_dir(lib, "!$%Pathfinder 2e")) + _scan(lib, tmp) + + system = _get_system_by_slug("pathfinder-2e") + assert system is not None + assert system.name == "Pathfinder 2e" + assert system.is_system_agnostic is False + assert system.is_one_page is False + + def test_internal_special_chars_preserved(self): + tmp, lib = _mk_lib() + _touch_pdf(_books_dir(lib, "!!Vampire: The Masquerade")) + _scan(lib, tmp) + + system = _get_system_by_slug("vampire-the-masquerade") + assert system is not None + assert system.name == "Vampire: The Masquerade" + + def test_prefix_and_nsfw_combined(self): + tmp, lib = _mk_lib() + _touch_pdf(_books_dir(lib, "!!Forbidden Lore (NSFW)")) + _scan(lib, tmp) + + system = _get_system_by_slug("forbidden-lore") + assert system is not None + assert system.name == "Forbidden Lore" + assert system.is_explicit is True diff --git a/backend/tests/test_library.py b/backend/tests/test_library.py index a689327..83bc0e8 100644 --- a/backend/tests/test_library.py +++ b/backend/tests/test_library.py @@ -1,7 +1,30 @@ """Tests for library stats and scan endpoints.""" +from unittest.mock import patch + +import httpx +import pytest + +from backend.routers.library import core as library_core from backend.tests.conftest import make_game_system, make_book +@pytest.fixture(autouse=True) +def _reset_release_cache(): + """Clear the module-level release cache so tests don't leak state.""" + library_core._latest_release_cache = (0.0, None) + yield + library_core._latest_release_cache = (0.0, None) + + +def _mock_github(status_code=200, tag_name="v2.0.0"): + """Build a fake httpx response for the GitHub releases endpoint.""" + resp = httpx.Response( + status_code, + json={"tag_name": tag_name} if tag_name is not None else {}, + ) + return patch.object(library_core.httpx, "get", return_value=resp) + + class TestLibraryStats: def test_stats_endpoint_accessible(self, client, admin_headers): resp = client.get("/api/stats", headers=admin_headers) @@ -88,6 +111,47 @@ def test_about_requires_auth(self, client): assert client.get("/api/about").status_code == 401 +class TestLatestRelease: + def test_returns_version_from_github(self, client, player_headers): + with _mock_github(tag_name="v2.0.0"): + resp = client.get("/api/latest-release", headers=player_headers) + assert resp.status_code == 200 + # Leading "v" is stripped for the frontend comparator. + assert resp.json() == {"latest_version": "2.0.0"} + + def test_returns_null_on_github_404(self, client, player_headers): + # A repo with no published release returns 404 from GitHub. + with _mock_github(status_code=404, tag_name=None): + resp = client.get("/api/latest-release", headers=player_headers) + assert resp.status_code == 200 + assert resp.json() == {"latest_version": None} + + def test_returns_null_on_network_error(self, client, player_headers): + with patch.object( + library_core.httpx, "get", side_effect=httpx.ConnectError("boom") + ): + resp = client.get("/api/latest-release", headers=player_headers) + assert resp.status_code == 200 + assert resp.json() == {"latest_version": None} + + def test_result_is_cached(self, client, player_headers): + with _mock_github(tag_name="v2.0.0") as mock_get: + client.get("/api/latest-release", headers=player_headers) + client.get("/api/latest-release", headers=player_headers) + # Second call is served from the cache — GitHub hit only once. + assert mock_get.call_count == 1 + + def test_disabled_skips_github(self, client, player_headers): + with patch.object(library_core, "DISABLE_VERSION_CHECKING", True): + with _mock_github(tag_name="v2.0.0") as mock_get: + resp = client.get("/api/latest-release", headers=player_headers) + assert resp.json() == {"latest_version": None} + assert mock_get.call_count == 0 + + def test_requires_auth(self, client): + assert client.get("/api/latest-release").status_code == 401 + + class TestScanStatus: def test_scan_status_accessible(self, client, admin_headers): resp = client.get("/api/scan-status", headers=admin_headers) diff --git a/backend/tests/test_lookups.py b/backend/tests/test_lookups.py new file mode 100644 index 0000000..5f6f4b9 --- /dev/null +++ b/backend/tests/test_lookups.py @@ -0,0 +1,251 @@ +"""Tests for the genre / system-family lookup API (issue #202).""" +from backend.tests.conftest import make_game_system + + +class TestGenres: + def test_defaults_seeded(self, client, admin_headers): + resp = client.get("/api/genres", headers=admin_headers) + assert resp.status_code == 200 + names = [g["name"] for g in resp.json()["genres"]] + assert "Science Fiction" in names + assert "Cyberpunk" in names + + def test_cyberpunk_nested_under_science_fiction(self, client, admin_headers): + genres = client.get("/api/genres", headers=admin_headers).json()["genres"] + by_name = {g["name"]: g for g in genres} + sci = by_name["Science Fiction"] + cyber = by_name["Cyberpunk"] + assert cyber["parent_id"] == sci["id"] + + def test_player_can_read_genres(self, client, player_headers): + assert client.get("/api/genres", headers=player_headers).status_code == 200 + + def test_create_custom_genre_admin(self, client, admin_headers): + resp = client.post("/api/genres", json={"name": "Solarpunk"}, headers=admin_headers) + assert resp.status_code == 200 + assert resp.json()["name"] == "Solarpunk" + assert resp.json()["is_default"] is False + + def test_create_child_genre(self, client, admin_headers): + genres = client.get("/api/genres", headers=admin_headers).json()["genres"] + parent = next(g for g in genres if g["name"] == "Fantasy") + resp = client.post( + "/api/genres", + json={"name": "Grimbright", "parent_id": parent["id"]}, + headers=admin_headers, + ) + assert resp.status_code == 200 + assert resp.json()["parent_id"] == parent["id"] + + def test_create_duplicate_rejected(self, client, admin_headers): + client.post("/api/genres", json={"name": "Noir"}, headers=admin_headers) + resp = client.post("/api/genres", json={"name": "noir"}, headers=admin_headers) + assert resp.status_code == 409 + + def test_create_blank_rejected(self, client, admin_headers): + resp = client.post("/api/genres", json={"name": " "}, headers=admin_headers) + assert resp.status_code == 422 + + def test_create_requires_admin(self, client, gm_headers): + resp = client.post("/api/genres", json={"name": "Weird"}, headers=gm_headers) + assert resp.status_code == 403 + + def test_delete_unused_genre(self, client, admin_headers): + created = client.post( + "/api/genres", json={"name": "Deletable"}, headers=admin_headers + ).json() + resp = client.delete(f"/api/genres/{created['id']}", headers=admin_headers) + assert resp.status_code == 200 + + def test_delete_in_use_blocked_without_force(self, client, admin_headers): + created = client.post( + "/api/genres", json={"name": "AttachedGenre"}, headers=admin_headers + ).json() + make_game_system(genres=["AttachedGenre"]) + resp = client.delete(f"/api/genres/{created['id']}", headers=admin_headers) + assert resp.status_code == 409 + assert resp.json()["detail"]["usage_count"] >= 1 + + def test_delete_in_use_with_force(self, client, admin_headers): + created = client.post( + "/api/genres", json={"name": "ForceGenre"}, headers=admin_headers + ).json() + make_game_system(genres=["ForceGenre"]) + resp = client.delete( + f"/api/genres/{created['id']}?force=true", headers=admin_headers + ) + assert resp.status_code == 200 + assert resp.json()["removed_usage"] >= 1 + + def test_delete_missing_genre(self, client, admin_headers): + resp = client.delete("/api/genres/does-not-exist", headers=admin_headers) + assert resp.status_code == 404 + + +class TestSystemFamilies: + def test_defaults_seeded(self, client, admin_headers): + resp = client.get("/api/system-families", headers=admin_headers) + assert resp.status_code == 200 + names = [f["name"] for f in resp.json()["families"]] + assert "Powered by the Apocalypse" in names + + def test_create_custom_family(self, client, admin_headers): + resp = client.post( + "/api/system-families", json={"name": "Havoc System"}, headers=admin_headers + ) + assert resp.status_code == 200 + assert resp.json()["name"] == "Havoc System" + + def test_create_duplicate_rejected(self, client, admin_headers): + client.post("/api/system-families", json={"name": "Ubiquity"}, headers=admin_headers) + resp = client.post( + "/api/system-families", json={"name": "ubiquity"}, headers=admin_headers + ) + assert resp.status_code == 409 + + def test_delete_in_use_blocked(self, client, admin_headers): + created = client.post( + "/api/system-families", json={"name": "AttachedFamily"}, headers=admin_headers + ).json() + make_game_system(system_family="AttachedFamily") + resp = client.delete(f"/api/system-families/{created['id']}", headers=admin_headers) + assert resp.status_code == 409 + + def test_delete_in_use_with_force(self, client, admin_headers): + created = client.post( + "/api/system-families", json={"name": "ForceFamily"}, headers=admin_headers + ).json() + make_game_system(system_family="ForceFamily") + resp = client.delete( + f"/api/system-families/{created['id']}?force=true", headers=admin_headers + ) + assert resp.status_code == 200 + + def test_create_requires_admin(self, client, player_headers): + resp = client.post( + "/api/system-families", json={"name": "Nope"}, headers=player_headers + ) + assert resp.status_code == 403 + + +class TestParentSystems: + def test_list_empty_by_default(self, client, admin_headers): + resp = client.get("/api/parent-systems", headers=admin_headers) + assert resp.status_code == 200 + assert resp.json()["parent_systems"] == [] + + def test_create_and_list(self, client, admin_headers): + created = client.post( + "/api/parent-systems", + json={"name": "Dungeons & Dragons"}, + headers=admin_headers, + ) + assert created.status_code == 200 + assert created.json()["name"] == "Dungeons & Dragons" + names = [ + p["name"] + for p in client.get("/api/parent-systems", headers=admin_headers).json()[ + "parent_systems" + ] + ] + assert "Dungeons & Dragons" in names + + def test_create_duplicate_rejected(self, client, admin_headers): + client.post("/api/parent-systems", json={"name": "Cyberpunk"}, headers=admin_headers) + resp = client.post( + "/api/parent-systems", json={"name": "cyberpunk"}, headers=admin_headers + ) + assert resp.status_code == 409 + + def test_player_can_read(self, client, player_headers): + assert client.get("/api/parent-systems", headers=player_headers).status_code == 200 + + def test_create_requires_admin(self, client, gm_headers): + resp = client.post("/api/parent-systems", json={"name": "Nope"}, headers=gm_headers) + assert resp.status_code == 403 + + def test_delete_in_use_blocked_then_forced(self, client, admin_headers): + created = client.post( + "/api/parent-systems", json={"name": "AttachedParent"}, headers=admin_headers + ).json() + make_game_system(parent_system="AttachedParent") + blocked = client.delete( + f"/api/parent-systems/{created['id']}", headers=admin_headers + ) + assert blocked.status_code == 409 + forced = client.delete( + f"/api/parent-systems/{created['id']}?force=true", headers=admin_headers + ) + assert forced.status_code == 200 + + +class TestLicenses: + def test_defaults_seeded(self, client, admin_headers): + resp = client.get("/api/licenses", headers=admin_headers) + assert resp.status_code == 200 + names = [lic["name"] for lic in resp.json()["licenses"]] + assert "OGL 1.0a" in names + + def test_create_custom(self, client, admin_headers): + resp = client.post( + "/api/licenses", json={"name": "My Homebrew License"}, headers=admin_headers + ) + assert resp.status_code == 200 + assert resp.json()["is_default"] is False + + def test_delete_in_use_by_system_blocked(self, client, admin_headers): + created = client.post( + "/api/licenses", json={"name": "SystemLicense"}, headers=admin_headers + ).json() + make_game_system(license="SystemLicense") + resp = client.delete(f"/api/licenses/{created['id']}", headers=admin_headers) + assert resp.status_code == 409 + + def test_player_can_read(self, client, player_headers): + assert client.get("/api/licenses", headers=player_headers).status_code == 200 + + def test_create_requires_admin(self, client, gm_headers): + resp = client.post("/api/licenses", json={"name": "Nope"}, headers=gm_headers) + assert resp.status_code == 403 + + +class TestDiceMaterials: + def test_defaults_seeded_with_groups(self, client, admin_headers): + resp = client.get("/api/dice-materials", headers=admin_headers) + assert resp.status_code == 200 + rows = resp.json()["dice_materials"] + by_name = {d["name"]: d for d in rows} + assert "D20" in by_name + assert by_name["D20"]["group"] == "Dice" + assert by_name["Tarot Cards"]["group"] == "Cards" + + def test_create_custom_defaults_to_custom_group(self, client, admin_headers): + resp = client.post( + "/api/dice-materials", json={"name": "Spinner"}, headers=admin_headers + ) + assert resp.status_code == 200 + assert resp.json()["group"] == "Custom" + + def test_create_with_group(self, client, admin_headers): + resp = client.post( + "/api/dice-materials", + json={"name": "Fudge Dice", "group": "Dice"}, + headers=admin_headers, + ) + assert resp.status_code == 200 + assert resp.json()["group"] == "Dice" + + def test_delete_in_use_blocked(self, client, admin_headers): + created = client.post( + "/api/dice-materials", json={"name": "AttachedDie"}, headers=admin_headers + ).json() + make_game_system(dice_materials=["AttachedDie"]) + resp = client.delete(f"/api/dice-materials/{created['id']}", headers=admin_headers) + assert resp.status_code == 409 + + def test_player_can_read(self, client, player_headers): + assert client.get("/api/dice-materials", headers=player_headers).status_code == 200 + + def test_create_requires_admin(self, client, gm_headers): + resp = client.post("/api/dice-materials", json={"name": "Nope"}, headers=gm_headers) + assert resp.status_code == 403 diff --git a/backend/tests/test_maps.py b/backend/tests/test_maps.py index dec1157..5a0cef8 100644 --- a/backend/tests/test_maps.py +++ b/backend/tests/test_maps.py @@ -1,5 +1,6 @@ """Tests for map and map folder endpoints.""" import hashlib +import uuid import pytest from backend.tests.conftest import make_map @@ -138,16 +139,20 @@ def test_gm_can_update_map(self, client, gm_headers, map_entry): assert resp.status_code == 200 assert resp.json() == {"status": "ok"} - def test_tags_are_lowercased_on_map_update(self, client, gm_headers): + def test_tags_keep_display_casing_on_map_update(self, client, gm_headers): + # Shared tags preserve the entered casing (display value); matching is + # case-insensitive under the hood. Returned tags are sorted by display. + # Unique tag names avoid reusing a display value set by another test. + uid = uuid.uuid4().hex[:6] m = make_map() resp = client.patch( f"/api/maps/{m.id}", - json={"tags": ["Draw Steel", "DUNGEON", "city"]}, + json={"tags": [f"Alpha{uid}", f"BRAVO{uid}", f"charlie{uid}"]}, headers=gm_headers, ) assert resp.status_code == 200 detail = client.get(f"/api/maps/{m.id}", headers=gm_headers).json() - assert detail["tags"] == ["draw steel", "dungeon", "city"] + assert detail["tags"] == [f"Alpha{uid}", f"BRAVO{uid}", f"charlie{uid}"] def test_duplicate_tags_deduplicated_on_map_update(self, client, gm_headers): m = make_map() @@ -158,6 +163,7 @@ def test_duplicate_tags_deduplicated_on_map_update(self, client, gm_headers): ) assert resp.status_code == 200 detail = client.get(f"/api/maps/{m.id}", headers=gm_headers).json() + # Case-variants collapse to one tag, keeping the first-entered display. assert detail["tags"] == ["draw steel"] def test_player_cannot_update_map(self, client, player_headers, map_entry): diff --git a/backend/tests/test_saved_filters.py b/backend/tests/test_saved_filters.py new file mode 100644 index 0000000..bae02cc --- /dev/null +++ b/backend/tests/test_saved_filters.py @@ -0,0 +1,146 @@ +"""Tests for the saved-filters API (server-side sort/filter presets).""" + + +class TestSavedFilters: + def test_create_and_list(self, client, admin_headers): + r = client.post( + "/api/saved-filters", + json={"scope": "systems", "name": "By pages", "state": {"sort": "page_count"}}, + headers=admin_headers, + ) + assert r.status_code == 200 + assert r.json()["name"] == "By pages" + assert r.json()["state"] == {"sort": "page_count"} + + rows = client.get("/api/saved-filters?scope=systems", headers=admin_headers).json()[ + "filters" + ] + assert any(f["name"] == "By pages" for f in rows) + + def test_blank_name_rejected(self, client, admin_headers): + r = client.post( + "/api/saved-filters", + json={"scope": "systems", "name": " ", "state": {}}, + headers=admin_headers, + ) + assert r.status_code == 422 + + def test_invalid_scope_rejected(self, client, admin_headers): + r = client.post( + "/api/saved-filters", + json={"scope": "nope", "name": "x", "state": {}}, + headers=admin_headers, + ) + assert r.status_code == 422 + + def test_list_invalid_scope_query(self, client, admin_headers): + r = client.get("/api/saved-filters?scope=nope", headers=admin_headers) + assert r.status_code == 400 + + def test_resave_overwrites_state(self, client, admin_headers): + client.post( + "/api/saved-filters", + json={"scope": "books", "name": "Dupe", "state": {"sort": "title"}}, + headers=admin_headers, + ) + r = client.post( + "/api/saved-filters", + json={"scope": "books", "name": "Dupe", "state": {"sort": "year"}}, + headers=admin_headers, + ) + assert r.status_code == 200 + assert r.json()["state"] == {"sort": "year"} + # No duplicate row created. + rows = client.get("/api/saved-filters?scope=books", headers=admin_headers).json()[ + "filters" + ] + assert len([f for f in rows if f["name"] == "Dupe"]) == 1 + + def test_only_one_default_per_scope(self, client, admin_headers): + a = client.post( + "/api/saved-filters", + json={"scope": "maps", "name": "A", "state": {}, "is_default": True}, + headers=admin_headers, + ).json() + b = client.post( + "/api/saved-filters", + json={"scope": "maps", "name": "B", "state": {}}, + headers=admin_headers, + ).json() + # Promote B to default → A must lose it. + client.patch( + f"/api/saved-filters/{b['id']}", json={"is_default": True}, headers=admin_headers + ) + rows = client.get("/api/saved-filters?scope=maps", headers=admin_headers).json()["filters"] + defaults = {f["name"]: f["is_default"] for f in rows} + assert defaults["A"] is False + assert defaults["B"] is True + assert a["is_default"] is True # was default at creation + + def test_default_isolated_per_scope(self, client, admin_headers): + client.post( + "/api/saved-filters", + json={"scope": "tokens", "name": "TokDefault", "state": {}, "is_default": True}, + headers=admin_headers, + ) + client.post( + "/api/saved-filters", + json={"scope": "audio", "name": "AudDefault", "state": {}, "is_default": True}, + headers=admin_headers, + ) + tok = client.get("/api/saved-filters?scope=tokens", headers=admin_headers).json()[ + "filters" + ] + aud = client.get("/api/saved-filters?scope=audio", headers=admin_headers).json()["filters"] + assert tok[0]["is_default"] is True + assert aud[0]["is_default"] is True + + def test_update_rename_and_state(self, client, admin_headers): + f = client.post( + "/api/saved-filters", + json={"scope": "systems", "name": "Old", "state": {"sort": "name"}}, + headers=admin_headers, + ).json() + r = client.patch( + f"/api/saved-filters/{f['id']}", + json={"name": "New", "state": {"sort": "year"}}, + headers=admin_headers, + ) + assert r.status_code == 200 + assert r.json()["name"] == "New" + assert r.json()["state"] == {"sort": "year"} + + def test_delete(self, client, admin_headers): + f = client.post( + "/api/saved-filters", + json={"scope": "systems", "name": "ToDelete", "state": {}}, + headers=admin_headers, + ).json() + assert ( + client.delete(f"/api/saved-filters/{f['id']}", headers=admin_headers).status_code + == 200 + ) + assert ( + client.delete(f"/api/saved-filters/{f['id']}", headers=admin_headers).status_code + == 404 + ) + + def test_update_missing_404(self, client, admin_headers): + assert ( + client.patch( + "/api/saved-filters/nope", json={"name": "x"}, headers=admin_headers + ).status_code + == 404 + ) + + def test_filters_are_per_user(self, client, admin_headers, gm_headers): + client.post( + "/api/saved-filters", + json={"scope": "systems", "name": "AdminOnly", "state": {}}, + headers=admin_headers, + ) + gm_rows = client.get("/api/saved-filters", headers=gm_headers).json()["filters"] + assert all(f["name"] != "AdminOnly" for f in gm_rows) + + def test_requires_auth(self, client): + assert client.get("/api/saved-filters").status_code == 401 diff --git a/backend/tests/test_systems_metadata.py b/backend/tests/test_systems_metadata.py new file mode 100644 index 0000000..edf185a --- /dev/null +++ b/backend/tests/test_systems_metadata.py @@ -0,0 +1,253 @@ +"""Tests for expanded system metadata, sort/filter, and serialization (#202).""" +from backend.tests.conftest import make_book, make_game_system + + +class TestSystemMetadataFields: + def test_new_fields_in_list(self, client, admin_headers): + make_game_system( + name="Blades Test", + slug="blades-test", + genres=["Fantasy", "Heist"], + dice_materials=["D6 pool"], + system_family="Forged in the Dark", + license="CC-BY", + year=2017, + urls=[{"label": "DriveThruRPG", "url": "http://example.com"}], + ) + resp = client.get("/api/systems", headers=admin_headers) + s = next(s for s in resp.json() if s["slug"] == "blades-test") + assert s["genres"] == ["Fantasy", "Heist"] + assert s["dice_materials"] == ["D6 pool"] + assert s["system_family"] == "Forged in the Dark" + assert s["license"] == "CC-BY" + assert s["year"] == 2017 + assert s["urls"][0]["label"] == "DriveThruRPG" + assert "total_page_count" in s + assert "is_one_page" in s + + def test_update_metadata(self, client, admin_headers): + sysobj = make_game_system(name="Patch Meta", slug="patch-meta") + resp = client.patch( + f"/api/systems/{sysobj.id}", + json={ + "genres": ["Horror", "Horror", " gothic "], + "system_family": "GUMSHOE", + "year": 2011, + "character_builder_urls": [{"label": "Sheet", "url": "http://s"}], + }, + headers=admin_headers, + ) + assert resp.status_code == 200 + got = client.get(f"/api/systems/{sysobj.id}", headers=admin_headers).json() + # De-duplicated case-insensitively, trimmed, case preserved. + assert got["genres"] == ["Horror", "gothic"] + assert got["system_family"] == "GUMSHOE" + assert got["character_builder_urls"][0]["url"] == "http://s" + + def test_update_parent_system_and_edition(self, client, admin_headers): + sysobj = make_game_system(name="Cyberpunk Red", slug="cp-red") + resp = client.patch( + f"/api/systems/{sysobj.id}", + json={"parent_system": "Cyberpunk", "edition": "Red", "license": "Custom"}, + headers=admin_headers, + ) + assert resp.status_code == 200 + got = client.get(f"/api/systems/{sysobj.id}", headers=admin_headers).json() + assert got["parent_system"] == "Cyberpunk" + assert got["edition"] == "Red" + assert got["license"] == "Custom" + + def test_new_fields_default_empty(self, client, admin_headers): + sysobj = make_game_system(name="Bare Sys", slug="bare-sys") + got = client.get(f"/api/systems/{sysobj.id}", headers=admin_headers).json() + assert got["parent_system"] == "" + assert got["edition"] == "" + + def test_total_page_count_aggregates(self, client, admin_headers): + sysobj = make_game_system(name="Pages Sys", slug="pages-sys") + make_book(system_id=sysobj.id, page_count=10) + make_book(system_id=sysobj.id, page_count=25) + resp = client.get("/api/systems", headers=admin_headers) + s = next(s for s in resp.json() if s["slug"] == "pages-sys") + assert s["total_page_count"] == 35 + assert s["book_count"] == 2 + + +class TestSystemSort: + def _slugs(self, rows, prefix): + return [r["slug"] for r in rows if r["slug"].startswith(prefix)] + + def test_sort_by_page_count(self, client, admin_headers): + a = make_game_system(name="Zsort A", slug="zsort-a") + b = make_game_system(name="Zsort B", slug="zsort-b") + make_book(system_id=a.id, page_count=5) + make_book(system_id=b.id, page_count=50) + rows = client.get( + "/api/systems?sort=page_count&order=desc", headers=admin_headers + ).json() + ordered = self._slugs(rows, "zsort-") + assert ordered.index("zsort-b") < ordered.index("zsort-a") + + def test_sort_by_name_desc(self, client, admin_headers): + make_game_system(name="Alpha Name", slug="namesort-alpha") + make_game_system(name="Beta Name", slug="namesort-beta") + rows = client.get("/api/systems?sort=name&order=desc", headers=admin_headers).json() + ordered = self._slugs(rows, "namesort-") + assert ordered.index("namesort-beta") < ordered.index("namesort-alpha") + + +class TestSystemFilter: + def test_filter_by_genre(self, client, admin_headers): + make_game_system(name="GenreFilt", slug="genrefilt", genres=["Steampunk"]) + rows = client.get("/api/systems?genre=Steampunk", headers=admin_headers).json() + assert any(s["slug"] == "genrefilt" for s in rows) + assert all("Steampunk" in (s.get("genres") or []) for s in rows) + + def test_filter_by_family(self, client, admin_headers): + make_game_system(name="FamFilt", slug="famfilt", system_family="Cypher System") + rows = client.get( + "/api/systems?family=Cypher System", headers=admin_headers + ).json() + assert any(s["slug"] == "famfilt" for s in rows) + + def test_filter_by_parent_system(self, client, admin_headers): + make_game_system( + name="ParentFilt", slug="parentfilt", parent_system="Dungeons & Dragons" + ) + make_game_system(name="OtherPar", slug="otherpar", parent_system="Cyberpunk") + rows = client.get( + "/api/systems?parent_system=Dungeons %26 Dragons", headers=admin_headers + ).json() + slugs = [s["slug"] for s in rows] + assert "parentfilt" in slugs + assert "otherpar" not in slugs + + def test_filter_by_edition(self, client, admin_headers): + make_game_system( + name="EdFilt", slug="edfilt", parent_system="Cyberpunk", edition="Red" + ) + rows = client.get("/api/systems?edition=Red", headers=admin_headers).json() + assert any(s["slug"] == "edfilt" for s in rows) + + def test_filter_by_license(self, client, admin_headers): + make_game_system(name="LicFilt", slug="licfilt", license="OGL 1.0a") + rows = client.get( + "/api/systems?license=OGL 1.0a", headers=admin_headers + ).json() + assert any(s["slug"] == "licfilt" for s in rows) + + def test_filter_explicit(self, client, admin_headers): + make_game_system(name="ExplFilt", slug="explfilt", is_explicit=True) + rows = client.get("/api/systems?explicit=true", headers=admin_headers).json() + assert all(s["is_explicit"] for s in rows) + assert any(s["slug"] == "explfilt" for s in rows) + + +class TestBookMetadata: + def test_book_new_fields(self, client, admin_headers): + sysobj = make_game_system(name="BookMeta Sys", slug="bookmeta-sys") + book = make_book( + system_id=sysobj.id, + artists=["Jane Artist"], + genres=["Grimdark"], + isbn="978-3-16-148410-0", + version="1.2", + language="en", + year=2019, + month=3, + day=14, + ) + got = client.get(f"/api/systems/{sysobj.id}", headers=admin_headers).json() + b = next(x for x in got["books"] if x["id"] == book.id) + assert b["artists"] == ["Jane Artist"] + assert b["genres"] == ["Grimdark"] + assert b["isbn"] == "978-3-16-148410-0" + assert b["version"] == "1.2" + assert b["month"] == 3 + assert b["day"] == 14 + + def test_book_license_override(self, client, admin_headers): + # A book can carry its own license (e.g. an OGL SRD in a proprietary system). + sysobj = make_game_system( + name="LicSys", slug="licsys", license="Proprietary / All Rights Reserved" + ) + book = make_book(system_id=sysobj.id) + resp = client.patch( + f"/api/books/{book.id}", json={"license": "OGL 1.0a"}, headers=admin_headers + ) + assert resp.status_code == 200 + got = client.get(f"/api/systems/{sysobj.id}", headers=admin_headers).json() + b = next(x for x in got["books"] if x["id"] == book.id) + assert b["license"] == "OGL 1.0a" + + def test_book_update_month_validation(self, client, admin_headers): + sysobj = make_game_system(name="BadDate Sys", slug="baddate-sys") + book = make_book(system_id=sysobj.id) + resp = client.patch( + f"/api/books/{book.id}", json={"month": 13}, headers=admin_headers + ) + assert resp.status_code == 422 + + def test_book_update_month_zero_rejected(self, client, admin_headers): + sysobj = make_game_system(name="ZeroMonth Sys", slug="zeromonth-sys") + book = make_book(system_id=sysobj.id) + resp = client.patch( + f"/api/books/{book.id}", json={"month": 0}, headers=admin_headers + ) + assert resp.status_code == 422 + + def test_book_update_day_out_of_range(self, client, admin_headers): + sysobj = make_game_system(name="BadDay Sys", slug="badday-sys") + book = make_book(system_id=sysobj.id) + resp = client.patch( + f"/api/books/{book.id}", json={"day": 32}, headers=admin_headers + ) + assert resp.status_code == 422 + + def test_book_update_valid_full_date(self, client, admin_headers): + sysobj = make_game_system(name="GoodDate Sys", slug="gooddate-sys") + book = make_book(system_id=sysobj.id) + resp = client.patch( + f"/api/books/{book.id}", + json={"year": 2020, "month": 6, "day": 15, "genres": [" Fantasy ", "fantasy"]}, + headers=admin_headers, + ) + assert resp.status_code == 200 + got = client.get(f"/api/books/{book.id}", headers=admin_headers).json() + assert (got["year"], got["month"], got["day"]) == (2020, 6, 15) + # Genres trimmed and de-duplicated case-insensitively. + assert got["genres"] == ["Fantasy"] + + def test_book_url_backfill_field(self, client, admin_headers): + sysobj = make_game_system(name="BookUrl Sys", slug="bookurl-sys") + book = make_book(system_id=sysobj.id) + client.patch( + f"/api/books/{book.id}", + json={"urls": [{"label": "DTRPG", "url": "http://x"}]}, + headers=admin_headers, + ) + got = client.get(f"/api/books/{book.id}", headers=admin_headers).json() + assert got["urls"][0]["label"] == "DTRPG" + + +class TestBookSortFilter: + def test_book_sort_by_page_count(self, client, admin_headers): + sysobj = make_game_system(name="BookSort Sys", slug="booksort-sys") + make_book(system_id=sysobj.id, title="Small", page_count=3) + make_book(system_id=sysobj.id, title="Large", page_count=300) + got = client.get( + f"/api/systems/{sysobj.id}?book_sort=page_count&book_order=desc", + headers=admin_headers, + ).json() + titles = [b["title"] for b in got["books"]] + assert titles.index("Large") < titles.index("Small") + + def test_book_filter_explicit(self, client, admin_headers): + sysobj = make_game_system(name="BookExpl Sys", slug="bookexpl-sys") + make_book(system_id=sysobj.id, title="Clean", is_explicit=False) + make_book(system_id=sysobj.id, title="Spicy", is_explicit=True) + got = client.get( + f"/api/systems/{sysobj.id}?explicit=true", headers=admin_headers + ).json() + titles = [b["title"] for b in got["books"]] + assert "Spicy" in titles and "Clean" not in titles diff --git a/backend/tests/test_tag_service.py b/backend/tests/test_tag_service.py new file mode 100644 index 0000000..12e0fe7 --- /dev/null +++ b/backend/tests/test_tag_service.py @@ -0,0 +1,576 @@ +"""Unit tests for the shared-tag service and its Alembic backfill (issue #235).""" +import importlib.util +import json +import os +import tempfile + +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +from backend.models.base import Base +from backend.models.db import init_db +from backend.services import tag_service + + +def _load_migration(): + """Import the numeric-prefixed 0008 migration module by path.""" + here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + path = os.path.join(here, "migrations", "versions", "0008_shared_tags.py") + spec = importlib.util.spec_from_file_location("mig_0008_shared_tags", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _session(): + """A fresh in-memory DB session with the full schema (tags tables included).""" + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + return sessionmaker(bind=engine)() + + +class TestNormalization: + def test_internal_is_lowercased_and_stripped(self): + assert tag_service.normalize_internal(" Draw Steel ") == "draw steel" + + def test_display_keeps_entered_casing(self): + assert tag_service.default_display(" GM Screen ") == "GM Screen" + + +class TestGetOrCreate: + def test_creates_tag_with_entered_display(self): + db = _session() + tag = tag_service.get_or_create_tag(db, "Draw Steel") + assert tag.internal == "draw steel" + assert tag.display == "Draw Steel" + + def test_matches_existing_by_internal_and_keeps_first_display(self): + db = _session() + first = tag_service.get_or_create_tag(db, "GM Screen") + # A differently-cased later entry resolves to the same tag, unchanged. + again = tag_service.get_or_create_tag(db, "gm screen") + assert again.id == first.id + assert again.display == "GM Screen" + + def test_blank_input_returns_none(self): + db = _session() + assert tag_service.get_or_create_tag(db, " ") is None + + +class TestSetAndReadResourceTags: + def test_set_then_read_roundtrip(self): + db = _session() + result = tag_service.set_resource_tags(db, "map", "m1", ["Forest", "Dungeon"]) + assert [t["display"] for t in result] == ["Forest", "Dungeon"] + read = tag_service.tags_for_resource(db, "map", "m1") + # Read is sorted by display, case-insensitive. + assert [t["internal"] for t in read] == ["dungeon", "forest"] + + def test_set_dedupes_by_internal(self): + db = _session() + result = tag_service.set_resource_tags(db, "map", "m1", ["Forest", "forest", "FOREST"]) + assert len(result) == 1 + assert result[0]["internal"] == "forest" + + def test_set_replaces_previous_tags(self): + db = _session() + tag_service.set_resource_tags(db, "map", "m1", ["Forest", "Cave"]) + tag_service.set_resource_tags(db, "map", "m1", ["Cave"]) + read = tag_service.tags_for_resource(db, "map", "m1") + assert [t["internal"] for t in read] == ["cave"] + + def test_tags_shared_across_resource_types(self): + db = _session() + tag_service.set_resource_tags(db, "map", "m1", ["Strahd"]) + tag_service.set_resource_tags(db, "book", "b1", ["strahd"]) + # Same internal key → same underlying tag row. + rows = db.execute(text("SELECT COUNT(*) FROM tags")).scalar() + assert rows == 1 + + def test_unknown_resource_type_raises(self): + db = _session() + try: + tag_service.set_resource_tags(db, "widget", "x", ["a"]) + except ValueError as e: + assert "widget" in str(e) + else: + raise AssertionError("expected ValueError") + + +class TestCategory: + def _cat(self, db, internal): + from backend.models import Tag + + return db.query(Tag).filter(Tag.internal == internal).first().category + + def test_new_tag_takes_its_first_category(self): + db = _session() + tag_service.set_resource_tags(db, "map", "m1", ["Forest"]) + assert self._cat(db, "forest") == "map" + + def test_reuse_in_same_category_stays_single(self): + db = _session() + tag_service.set_resource_tags(db, "map", "m1", ["Forest"]) + tag_service.set_resource_tags(db, "map", "m2", ["forest"]) + assert self._cat(db, "forest") == "map" + + def test_second_category_promotes_to_shared(self): + db = _session() + tag_service.set_resource_tags(db, "map", "m1", ["Strahd"]) + tag_service.set_resource_tags(db, "book", "b1", ["strahd"]) + assert self._cat(db, "strahd") == "shared" + + def test_shared_stays_shared(self): + db = _session() + tag_service.set_resource_tags(db, "map", "m1", ["Strahd"]) + tag_service.set_resource_tags(db, "book", "b1", ["strahd"]) + tag_service.set_resource_tags(db, "map", "m2", ["strahd"]) # back to map + assert self._cat(db, "strahd") == "shared" + + def test_get_or_create_default_category_is_shared(self): + db = _session() + tag = tag_service.get_or_create_tag(db, "Programmatic") + assert tag.category == "shared" + + +class TestBatchAndLookups: + def test_tags_for_resources_batch(self): + db = _session() + tag_service.set_resource_tags(db, "map", "m1", ["Forest"]) + tag_service.set_resource_tags(db, "map", "m2", ["Cave"]) + batch = tag_service.tags_for_resources(db, "map", ["m1", "m2", "m3"]) + assert batch["m1"][0]["internal"] == "forest" + assert batch["m2"][0]["internal"] == "cave" + assert "m3" not in batch # no tags → omitted + + def test_resources_for_tag(self): + db = _session() + tag_service.set_resource_tags(db, "map", "m1", ["Strahd"]) + tag_service.set_resource_tags(db, "book", "b1", ["Strahd"]) + res = tag_service.resources_for_tag(db, "STRAHD") + pairs = {(r["resource_type"], r["resource_id"]) for r in res} + assert pairs == {("map", "m1"), ("book", "b1")} + + def test_resources_for_tag_filtered_by_type(self): + db = _session() + tag_service.set_resource_tags(db, "map", "m1", ["Strahd"]) + tag_service.set_resource_tags(db, "book", "b1", ["Strahd"]) + res = tag_service.resources_for_tag(db, "strahd", resource_type="book") + assert res == [{"resource_type": "book", "resource_id": "b1"}] + + def test_resources_for_unknown_tag_is_empty(self): + db = _session() + assert tag_service.resources_for_tag(db, "nope") == [] + + def test_tags_in_use_scoped_to_type_with_counts(self): + db = _session() + tag_service.set_resource_tags(db, "map", "m1", ["Forest"]) + tag_service.set_resource_tags(db, "map", "m2", ["Forest"]) + tag_service.set_resource_tags(db, "book", "b1", ["Lore"]) + # Scoped to maps: only Forest, count 2. "Lore" (book-only) excluded. + in_use = tag_service.tags_in_use(db, "map") + assert in_use == [ + {"internal": "forest", "display": "Forest", "category": "map", "count": 2} + ] + + def test_tags_in_use_unscoped(self): + db = _session() + tag_service.set_resource_tags(db, "map", "m1", ["Forest"]) + tag_service.set_resource_tags(db, "book", "b1", ["Lore"]) + internals = {t["internal"] for t in tag_service.tags_in_use(db)} + assert internals == {"forest", "lore"} + + def test_prune_orphan_tags(self): + db = _session() + tag_service.set_resource_tags(db, "map", "m1", ["Forest"]) + tag_service.set_resource_tags(db, "map", "m1", []) # unlink → orphan + removed = tag_service.prune_orphan_tags(db) + assert removed == 1 + assert db.execute(text("SELECT COUNT(*) FROM tags")).scalar() == 0 + + +class TestFolderTags: + def _map_in_folder(self, db, folder, rel_name): + from backend.models import GenericMap + + mid = f"m-{rel_name}" + db.add( + GenericMap( + id=mid, + filename=f"{rel_name}.png", + filepath=f"/x/{rel_name}.png", + relative_path=f"maps/{folder}/{rel_name}.png", + ) + ) + db.commit() + return mid + + def test_resolves_folder_tags_to_contained_items(self): + from backend.models import MapFolder + + db = _session() + mid = self._map_in_folder(db, "Swamps", "bog") + db.add(MapFolder(path="Swamps", tags=["Wetland", "Outdoor"])) + db.commit() + + ft = tag_service.folder_tags_in_use(db, "map") + assert set(ft) == {"wetland", "outdoor"} + assert ft["wetland"]["display"] == "Wetland" + assert ft["wetland"]["refs"] == [{"resource_type": "map", "resource_id": mid}] + + def test_scoped_to_resource_type(self): + from backend.models import MapFolder + + db = _session() + self._map_in_folder(db, "Swamps", "bog") + db.add(MapFolder(path="Swamps", tags=["Wetland"])) + db.commit() + # Scoping to a different type yields nothing. + assert tag_service.folder_tags_in_use(db, "token") == {} + + def test_remove_tag_from_folders(self): + from backend.models import MapFolder + + db = _session() + db.add(MapFolder(path="Swamps", tags=["Wetland", "Outdoor"])) + db.add(MapFolder(path="Caves", tags=["Wetland"])) + db.add(MapFolder(path="Peaks", tags=["Snowy"])) + db.commit() + # Case-insensitive match; strips from every folder that carries it. + changed = tag_service.remove_tag_from_folders(db, "WETLAND") + db.commit() + assert changed == 2 + remaining = {p: f.tags for p, f in ((r.path, r) for r in db.query(MapFolder).all())} + assert remaining["Swamps"] == ["Outdoor"] + assert remaining["Caves"] == [] + assert remaining["Peaks"] == ["Snowy"] # untouched folder unchanged + + def test_nested_items_included(self): + from backend.models import MapFolder + + db = _session() + nested = self._map_in_folder(db, "Swamps/Deep", "mire") + db.add(MapFolder(path="Swamps", tags=["Wetland"])) + db.commit() + refs = tag_service.folder_tags_in_use(db, "map")["wetland"]["refs"] + assert {r["resource_id"] for r in refs} == {nested} + + +class TestFolderTagCatalog: + """Folder tags register catalog rows; display comes from the catalog so a + rename sticks and tags.json can't overwrite it (read-only library).""" + + def _map_in_folder(self, db, folder, rel_name): + from backend.models import GenericMap + + mid = f"m-{rel_name}" + db.add( + GenericMap( + id=mid, + filename=f"{rel_name}.png", + filepath=f"/x/{rel_name}.png", + relative_path=f"maps/{folder}/{rel_name}.png", + ) + ) + db.commit() + return mid + + def test_register_folder_tags_creates_rows_and_returns_internals(self): + from backend.models import Tag + + db = _session() + internals = tag_service.register_folder_tags(db, ["Wetland", "Outdoor"], category="map") + db.commit() + assert internals == ["wetland", "outdoor"] + rows = {t.internal: t for t in db.query(Tag).all()} + assert rows["wetland"].display == "Wetland" # entered casing → default display + assert rows["wetland"].category == "map" + + def test_folder_display_comes_from_catalog(self): + from backend.models import MapFolder + + db = _session() + mid = self._map_in_folder(db, "Swamps", "bog") + # Catalog row has nicer casing; folder stores the internal key. + tag_service.get_or_create_tag(db, "Wetland", category="map") + db.add(MapFolder(path="Swamps", tags=["wetland"])) + db.commit() + ft = tag_service.folder_tags_in_use(db, "map") + assert ft["wetland"]["display"] == "Wetland" + assert ft["wetland"]["refs"] == [{"resource_type": "map", "resource_id": mid}] + # folder_display_tags resolves the stored internal to the catalog display. + assert tag_service.folder_display_tags(db, ["wetland"]) == ["Wetland"] + + def test_folder_display_falls_back_to_key_without_catalog_row(self): + db = _session() + assert tag_service.folder_display_tags(db, ["orphan"]) == ["orphan"] + + def test_rename_materializes_folder_only_tag(self): + from backend.models import MapFolder, Tag + + db = _session() + # A folder-only tag: exists in JSON, no Tag row yet. + db.add(MapFolder(path="Swamps", tags=["wetland"])) + db.commit() + assert db.query(Tag).filter_by(internal="wetland").first() is None + # Rename must not fail; it creates the catalog row so the display persists. + tag = tag_service.rename_tag(db, "wetland", "Wetlands") + db.commit() + assert tag is not None + assert tag.display == "Wetlands" + # Renamed to a new key → folder JSON is re-keyed too. + folder = db.query(MapFolder).filter_by(path="Swamps").first() + assert folder.tags == ["wetlands"] + + def test_add_resource_tags_is_additive(self): + db = _session() + tag_service.set_resource_tags(db, "map", "m1", ["Forest"]) + db.commit() + # Adding does not remove the existing tag. + added = tag_service.add_resource_tags(db, "map", "m1", ["Cave", "Forest"]) + db.commit() + assert [t["internal"] for t in added] == ["cave"] # Forest already present, skipped + read = tag_service.tags_for_resource(db, "map", "m1") + assert {t["internal"] for t in read} == {"forest", "cave"} + + +class TestBookFolderTags: + def _book(self, db, system_id, category, sub, name): + from backend.models import Book + + bid = f"b-{name}" + # relative_path: books/{SystemName}/{categoryDir}/{sub…}/{file} + sub_part = f"{sub}/" if sub else "" + db.add( + Book( + id=bid, + title=name, + filename=f"{name}.pdf", + filepath=f"/x/{name}.pdf", + relative_path=f"books/Sys/{category}/{sub_part}{name}.pdf", + game_system_id=system_id, + category=category, + ) + ) + db.commit() + return bid + + def test_book_folder_tags_resolve_to_books(self): + from backend.models import BookFolder + + db = _session() + bid = self._book(db, "sys1", "adventures", "curse", "strahd") + db.add(BookFolder(path="sys1/adventures/curse", tags=["Gothic"])) + db.commit() + + ft = tag_service.folder_tags_in_use(db, "book") + assert set(ft) == {"gothic"} + assert ft["gothic"]["refs"] == [{"resource_type": "book", "resource_id": bid}] + + def test_book_folders_scoped_out_for_other_types(self): + from backend.models import BookFolder + + db = _session() + self._book(db, "sys1", "adventures", "curse", "strahd") + db.add(BookFolder(path="sys1/adventures/curse", tags=["Gothic"])) + db.commit() + assert tag_service.folder_tags_in_use(db, "map") == {} + + def test_folders_for_tag_shows_subfolder_path_only(self): + from backend.models import BookFolder + + db = _session() + bid = self._book(db, "sys1", "adventures", "curse/strahd", "book1") + db.add(BookFolder(path="sys1/adventures/curse", tags=["Gothic"])) + db.commit() + groups = tag_service.folders_for_tag(db, "gothic") + assert len(groups) == 1 + # The system_id/category prefix is dropped from the displayed path. + assert groups[0] == { + "resource_type": "book", + "path": "curse", + "items": [{"resource_type": "book", "resource_id": bid}], + } + + def test_book_folder_type_in_folder_types_for_tag(self): + from backend.models import BookFolder + + db = _session() + db.add(BookFolder(path="sys1/adventures/curse", tags=["Gothic"])) + db.commit() + assert tag_service.folder_types_for_tag(db, "gothic") == {"book"} + + def test_remove_and_rekey_cover_book_folders(self): + from backend.models import BookFolder + + db = _session() + # Folders store internal keys; display lives in the catalog. + db.add(BookFolder(path="sys1/adventures/curse", tags=["gothic", "old"])) + db.commit() + # Rename re-keys the JSON entry in book folders too (to the new internal). + tag_service.get_or_create_tag(db, "Old", category="book") + db.commit() + tag_service.rename_tag(db, "old", "Ancient") + db.commit() + folder = db.query(BookFolder).first() + assert "ancient" in folder.tags and "old" not in folder.tags + # And removal strips it. + tag_service.remove_tag_from_folders(db, "gothic") + db.commit() + assert "gothic" not in db.query(BookFolder).first().tags + + +class TestEffectiveCategory: + def test_single_type_is_that_type(self): + assert tag_service.effective_category("map", {"map"}) == "map" + + def test_stored_shared_stays_shared(self): + assert tag_service.effective_category("shared", {"map"}) == "shared" + + def test_direct_and_folder_across_types_is_shared(self): + # Stored as book (direct), also used on a map folder → shared. + assert tag_service.effective_category("book", {"map"}) == "shared" + + def test_folder_only_single_type(self): + assert tag_service.effective_category(None, {"book"}) == "book" + + def test_folder_only_multiple_types_shared(self): + assert tag_service.effective_category(None, {"book", "map"}) == "shared" + + +class TestMigrationBackfill: + def test_init_db_creates_tag_tables(self): + path = os.path.join(tempfile.mkdtemp(), "t.db") + init_db(path) + engine = create_engine(f"sqlite:///{path}") + with engine.connect() as conn: + names = { + r[0] + for r in conn.execute( + text("SELECT name FROM sqlite_master WHERE type='table'") + ).fetchall() + } + assert {"tags", "resource_tags"} <= names + + def _pre_migration_db(self): + """A minimal pre-0009 schema: item tables still carrying a JSON ``tags`` + column, plus the empty shared-tag tables 0008 creates. Lets the 0008 + backfill be exercised in isolation now that a live DB has dropped the + item ``tags`` columns (0009).""" + path = os.path.join(tempfile.mkdtemp(), "t.db") + engine = create_engine(f"sqlite:///{path}") + with engine.begin() as conn: + conn.execute( + text( + "CREATE TABLE books (id TEXT PRIMARY KEY, title TEXT, filename TEXT, " + "filepath TEXT, relative_path TEXT, tags JSON)" + ) + ) + conn.execute( + text( + "CREATE TABLE generic_maps (id TEXT PRIMARY KEY, filename TEXT, " + "filepath TEXT, relative_path TEXT, tags JSON)" + ) + ) + conn.execute( + text( + "CREATE TABLE tokens (id TEXT PRIMARY KEY, filename TEXT, " + "filepath TEXT, relative_path TEXT, tags JSON)" + ) + ) + conn.execute( + text( + "CREATE TABLE tags (id TEXT PRIMARY KEY, internal TEXT UNIQUE, " + "display TEXT, created_at DATETIME)" + ) + ) + conn.execute( + text( + "CREATE TABLE resource_tags (id TEXT PRIMARY KEY, tag_id TEXT, " + "resource_type TEXT, resource_id TEXT, created_at DATETIME)" + ) + ) + return engine + + def test_backfill_migrates_json_tags(self): + mig = _load_migration() + engine = self._pre_migration_db() + with engine.begin() as conn: + conn.execute( + text( + "INSERT INTO books (id, title, filename, filepath, relative_path, tags) " + "VALUES ('b1', 'B', 'b.pdf', '/b.pdf', 'books/S/b.pdf', :tags)" + ), + {"tags": json.dumps(["Strahd", "Undead"])}, + ) + conn.execute( + text( + "INSERT INTO generic_maps (id, filename, filepath, relative_path, tags) " + "VALUES ('m1', 'm.png', '/m.png', 'maps/m.png', :tags)" + ), + {"tags": json.dumps(["strahd"])}, + ) + with engine.begin() as conn: + mig._backfill(conn) + with engine.connect() as conn: + # "Strahd" and "strahd" collapse to one tag. + tag_count = conn.execute(text("SELECT COUNT(*) FROM tags")).scalar() + assert tag_count == 2 # strahd, undead + links = conn.execute( + text( + "SELECT resource_type, resource_id FROM resource_tags " + "JOIN tags ON tags.id = resource_tags.tag_id WHERE tags.internal='strahd'" + ) + ).fetchall() + assert {(r[0], r[1]) for r in links} == {("book", "b1"), ("map", "m1")} + display = conn.execute( + text("SELECT display FROM tags WHERE internal='strahd'") + ).scalar() + assert display == "Strahd" + + def test_backfill_is_idempotent(self): + mig = _load_migration() + engine = self._pre_migration_db() + with engine.begin() as conn: + conn.execute( + text( + "INSERT INTO tokens (id, filename, filepath, relative_path, tags) " + "VALUES ('t1', 't.png', '/t.png', 'tokens/t.png', :tags)" + ), + {"tags": json.dumps(["Goblin"])}, + ) + with engine.begin() as conn: + mig._backfill(conn) + with engine.begin() as conn: + mig._backfill(conn) # second run must not duplicate links + with engine.connect() as conn: + links = conn.execute(text("SELECT COUNT(*) FROM resource_tags")).scalar() + assert links == 1 + + +class TestDropColumnsMigration: + def test_0009_self_heals_past_leftover_temp_table(self): + """A ``_alembic_tmp_*`` table left by a prior interrupted run must not + block the 0009 column drop (regression for the multi-worker batch-alter + collision seen in production).""" + from alembic import command + + from backend.models.db import _alembic_config + + path = os.path.join(tempfile.mkdtemp(), "t.db") + init_db(path) # fully migrated (item tags already dropped) + engine = create_engine(f"sqlite:///{path}") + with engine.begin() as conn: + # Re-add a tags column and rewind so 0009 runs again over it, and + # plant the leftover temp table that used to make the retry crash. + conn.execute(text("ALTER TABLE game_systems ADD COLUMN tags JSON")) + conn.execute(text("CREATE TABLE _alembic_tmp_game_systems (id TEXT)")) + with engine.connect() as conn: + cfg = _alembic_config(conn) + command.stamp(cfg, "c7a9e1f2b8d4") # back to pre-0009 + command.upgrade(cfg, "head") # must not raise + from sqlalchemy import inspect + + insp = inspect(engine) + assert "tags" not in {c["name"] for c in insp.get_columns("game_systems")} + assert not insp.has_table("_alembic_tmp_game_systems") diff --git a/backend/tests/test_tags_json.py b/backend/tests/test_tags_json.py index 05da6ca..e3ec828 100644 --- a/backend/tests/test_tags_json.py +++ b/backend/tests/test_tags_json.py @@ -16,8 +16,31 @@ Audio, AudioFolder, GameSystem, + Tag, ) from backend.indexer import _load_tags_json, _apply_tags_from_library +from backend.services import tag_service + + +def _sync_item_tags(resource_type: str, resource_id: str, tags) -> None: + """Apply item tags via the shared-tag service (items have no ``tags`` column).""" + if not tags: + return + db = SessionLocal() + try: + tag_service.set_resource_tags(db, resource_type, resource_id, tags) + db.commit() + finally: + db.close() + + +def _tags_of(resource_type: str, resource_id: str) -> list[str]: + """A resource's shared tags as display strings (sorted by display).""" + db = SessionLocal() + try: + return tag_service.display_tags_for_resource(db, resource_type, resource_id) + finally: + db.close() # --------------------------------------------------------------------------- @@ -47,14 +70,14 @@ def _add_map(lib: Path, rel: str, tags=None) -> GenericMap: filename=Path(rel).name, filepath=full, relative_path=rel, - tags=tags or [], ) db.add(m) db.commit() db.refresh(m) - return m finally: db.close() + _sync_item_tags("map", m.id, tags) + return m def _add_token(lib: Path, rel: str, tags=None) -> Token: @@ -66,14 +89,14 @@ def _add_token(lib: Path, rel: str, tags=None) -> Token: filename=Path(rel).name, filepath=full, relative_path=rel, - tags=tags or [], ) db.add(t) db.commit() db.refresh(t) - return t finally: db.close() + _sync_item_tags("token", t.id, tags) + return t def _add_system(slug: str, tags=None) -> GameSystem: @@ -84,14 +107,14 @@ def _add_system(slug: str, tags=None) -> GameSystem: id=str(uuid.uuid4()), name=f"System {uid}", slug=slug, - tags=tags or [], ) db.add(sys) db.commit() db.refresh(sys) - return sys finally: db.close() + _sync_item_tags("system", sys.id, tags) + return sys def _get_map(map_id: str) -> GenericMap | None: @@ -135,14 +158,14 @@ def _add_audio(lib: Path, rel: str, tags=None) -> Audio: filename=Path(rel).name, filepath=full, relative_path=rel, - tags=tags or [], ) db.add(a) db.commit() db.refresh(a) - return a finally: db.close() + _sync_item_tags("audio", a.id, tags) + return a def _get_audio(audio_id: str) -> Audio | None: @@ -222,18 +245,20 @@ def test_load_tags_json_strips_empty_strings_from_tags(): assert result["."] == ["dungeon"] -def test_load_tags_json_lowercases_tags(): +def test_load_tags_json_keeps_entered_casing(): + # tags.json keeps the entered casing; it becomes the default display for a new + # tag, and the tag service never overwrites an existing tag's display. tmp = tempfile.mkdtemp() _write_json(Path(tmp) / "tags.json", {".": ["Draw Steel", "FANTASY", "dungeon"]}) result = _load_tags_json(tmp) - assert result["."] == ["draw steel", "fantasy", "dungeon"] + assert result["."] == ["Draw Steel", "FANTASY", "dungeon"] -def test_load_tags_json_deduplicates_after_lowercasing(): +def test_load_tags_json_deduplicates_by_key_keeping_first_casing(): tmp = tempfile.mkdtemp() _write_json(Path(tmp) / "tags.json", {".": ["Draw Steel", "draw steel", "DRAW STEEL"]}) result = _load_tags_json(tmp) - assert result["."] == ["draw steel"] + assert result["."] == ["Draw Steel"] # --------------------------------------------------------------------------- @@ -253,7 +278,7 @@ def test_apply_sets_tags_on_map_file(): _write_json(maps_dir / "tags.json", {"cave.png": ["cave", "dungeon"]}) _run(lib) - assert _get_map(m.id).tags == ["cave", "dungeon"] + assert _tags_of("map", m.id) == ["cave", "dungeon"] def test_apply_sets_tags_on_map_folder_dot(): @@ -296,15 +321,17 @@ def test_apply_sets_tags_on_map_file_in_subfolder(): _write_json(maps_dir / "tags.json", {"caves/ice-cave.png": ["ice", "cold"]}) _run(lib) - assert _get_map(m.id).tags == ["ice", "cold"] + assert sorted(_tags_of("map", m.id)) == ["cold", "ice"] -def test_apply_updates_existing_map_folder_tags(): +def test_apply_adds_to_existing_map_folder_tags(): + # The library is read-only, so tags.json is additive: it adds tags without + # removing user-set folder tags (stored as internal keys). _, lib = _mk_lib() maps_dir = lib / "maps" / "Updater" maps_dir.mkdir(parents=True) - # Pre-create a folder record with old tags + # Pre-create a folder record with an existing (user-set) tag. db = SessionLocal() try: db.add(MapFolder(path="Updater", tags=["old"])) @@ -316,7 +343,8 @@ def test_apply_updates_existing_map_folder_tags(): _run(lib) folder = _get_map_folder("Updater") - assert folder.tags == ["new", "fresh"] + # Existing "old" is kept; the new keys are appended (never removed). + assert folder.tags == ["old", "new", "fresh"] def test_apply_ignores_unknown_file_key_gracefully(): @@ -346,7 +374,7 @@ def test_apply_sets_tags_on_token_file(): _write_json(tokens_dir / "tags.json", {"goblin.png": ["monster", "small"]}) _run(lib) - assert _get_token(t.id).tags == ["monster", "small"] + assert _tags_of("token", t.id) == ["monster", "small"] def test_apply_sets_tags_on_token_folder(): @@ -393,18 +421,21 @@ def test_apply_sets_tags_on_audio_file(): _write_json(audio_dir / "tags.json", {"tavern.mp3": ["ambient", "tavern"]}) _run(lib) - assert _get_audio(a.id).tags == ["ambient", "tavern"] + assert _tags_of("audio", a.id) == ["ambient", "tavern"] def test_apply_sets_tags_on_audio_folder(): _, lib = _mk_lib() - audio_dir = lib / "audio" / "Soundscapes" + # Unique folder name so the additive merge isn't polluted by an AudioFolder + # another test seeded in the shared DB. + name = f"Soundscapes{uuid.uuid4().hex[:6]}" + audio_dir = lib / "audio" / name audio_dir.mkdir(parents=True) _write_json(audio_dir / "tags.json", {".": ["soundscape", "atmosphere"]}) _run(lib) - folder = _get_audio_folder("Soundscapes") + folder = _get_audio_folder(name) assert folder is not None assert folder.tags == ["soundscape", "atmosphere"] @@ -445,7 +476,7 @@ def test_apply_sets_tags_on_game_system(): _run(lib) system = _get_system(slug) - assert system.tags == ["fantasy", "5e"] + assert sorted(_tags_of("system", system.id)) == ["5e", "fantasy"] # --------------------------------------------------------------------------- @@ -465,7 +496,7 @@ def test_apply_tags_json_in_subdirectory(): _write_json(sub / "tags.json", {"river.png": ["water", "outdoor"]}) _run(lib) - assert _get_map(m.id).tags == ["water", "outdoor"] + assert sorted(_tags_of("map", m.id)) == ["outdoor", "water"] # --------------------------------------------------------------------------- @@ -484,7 +515,7 @@ def test_apply_noop_when_no_tags_json(): _run(lib) # Tags should be unchanged - assert _get_map(m.id).tags == ["keep-me"] + assert _tags_of("map", m.id) == ["keep-me"] # --------------------------------------------------------------------------- @@ -502,3 +533,75 @@ def test_apply_does_not_error_when_tokens_dir_missing(): _, lib = _mk_lib() # No tokens/ dir at all _run(lib) # must not raise + + +# --------------------------------------------------------------------------- +# Read-only library: tags.json is additive, catalog owns the display +# --------------------------------------------------------------------------- + + +def _tag_row(internal: str) -> Tag | None: + db = SessionLocal() + try: + return db.query(Tag).filter_by(internal=internal).first() + finally: + db.close() + + +def test_tags_json_registers_catalog_row_with_entered_casing(): + _, lib = _mk_lib() + maps_dir = lib / "maps" / "Registrar" + maps_dir.mkdir(parents=True) + _write_json(maps_dir / "tags.json", {".": ["Gothic Horror"]}) + _run(lib) + + # A catalog row exists with the entered casing as its display; the folder + # stores the internal key. + row = _tag_row("gothic horror") + assert row is not None and row.display == "Gothic Horror" + assert _get_map_folder("Registrar").tags == ["gothic horror"] + + +def test_rescan_does_not_overwrite_edited_display(): + _, lib = _mk_lib() + maps_dir = lib / "maps" / "Keeper" + maps_dir.mkdir(parents=True) + _write_json(maps_dir / "tags.json", {".": ["wetland"]}) + _run(lib) + + # User edits the tag's display casing (same internal key) in the DB. + db = SessionLocal() + try: + tag_service.rename_tag(db, "wetland", "Wetland") # same key, nicer casing + db.commit() + finally: + db.close() + # Sanity: display updated, key unchanged. + assert _tag_row("wetland").display == "Wetland" + + # A later rescan reads tags.json again but must NOT revert the display + # (tags.json only sets the internal key, never overwrites an existing + # tag's display). + _run(lib) + assert _tag_row("wetland").display == "Wetland" + + +def test_rescan_does_not_remove_user_added_folder_tags(): + _, lib = _mk_lib() + maps_dir = lib / "maps" / "Additive" + maps_dir.mkdir(parents=True) + _write_json(maps_dir / "tags.json", {".": ["fromjson"]}) + _run(lib) + + # User adds another folder tag via the DB (as the API would). + db = SessionLocal() + try: + folder = db.query(MapFolder).filter_by(path="Additive").first() + folder.tags = list(folder.tags) + ["byhand"] + db.commit() + finally: + db.close() + + # A rescan re-applies tags.json additively; the user's tag survives. + _run(lib) + assert set(_get_map_folder("Additive").tags) == {"fromjson", "byhand"} diff --git a/backend/tests/test_tags_router.py b/backend/tests/test_tags_router.py new file mode 100644 index 0000000..9cdb908 --- /dev/null +++ b/backend/tests/test_tags_router.py @@ -0,0 +1,356 @@ +"""Integration tests for the /api/tags router and tag dual-write (issue #235).""" +import uuid + +from backend.config import SessionLocal +from backend.models import MapFolder +from backend.tests.conftest import ( + make_audio, + make_book, + make_game_system, + make_map, + make_token, +) + + +def _map_in_folder_with_folder_tags(folder: str, tags: list): + """A map inside ``maps//`` plus a MapFolder row tagged via tags.json. + Returns the map. Folder tags live on the folder table, not the shared tags.""" + m = make_map(relative_path=f"maps/{folder}/{uuid.uuid4().hex[:6]}.png") + db = SessionLocal() + try: + if not db.query(MapFolder).filter_by(path=folder).first(): + db.add(MapFolder(path=folder, tags=tags)) + db.commit() + finally: + db.close() + return m + + +def _tag_a_map(client, headers, tags): + m = make_map() + resp = client.patch(f"/api/maps/{m.id}", json={"tags": tags}, headers=headers) + assert resp.status_code == 200, resp.text + return m + + +class TestDualWrite: + def test_patching_map_tags_populates_shared_tags(self, client, admin_headers): + _tag_a_map(client, admin_headers, ["Forest", "Cave"]) + listing = client.get("/api/tags?in_use_by=map", headers=admin_headers).json() + internals = {t["internal"] for t in listing["tags"]} + assert {"forest", "cave"} <= internals + + def test_patching_book_tags_shares_with_map_tag(self, client, admin_headers): + system = make_game_system() + book = make_book(system.id) + _tag_a_map(client, admin_headers, ["Strahd"]) + client.patch(f"/api/books/{book.id}", json={"tags": ["strahd"]}, headers=admin_headers) + # Same internal key across a map and a book → one tag, two items. + items = client.get("/api/tags/strahd/items", headers=admin_headers).json() + types = {i["item_type"] for i in items["items"]} + assert {"map", "book"} <= types + + +class TestListTags: + def test_in_use_by_scopes_to_resource_type(self, client, admin_headers): + system = make_game_system() + book = make_book(system.id) + client.patch( + f"/api/books/{book.id}", json={"tags": ["BookOnly"]}, headers=admin_headers + ) + # A book-only tag must not appear when scoping to maps. + maps = client.get("/api/tags?in_use_by=map", headers=admin_headers).json() + assert "bookonly" not in {t["internal"] for t in maps["tags"]} + books = client.get("/api/tags?in_use_by=book", headers=admin_headers).json() + assert "bookonly" in {t["internal"] for t in books["tags"]} + + def test_invalid_in_use_by_returns_400(self, client, admin_headers): + resp = client.get("/api/tags?in_use_by=widget", headers=admin_headers) + assert resp.status_code == 400 + + def test_list_includes_category_single_and_shared(self, client, admin_headers): + system = make_game_system() + book = make_book(system.id) + m = make_map() + book_only = f"BookCat{uuid.uuid4().hex[:6]}" + shared = f"SharedCat{uuid.uuid4().hex[:6]}" + client.patch(f"/api/books/{book.id}", json={"tags": [book_only]}, headers=admin_headers) + # Same tag on a book and a map → promoted to shared. + client.patch(f"/api/books/{book.id}", json={"tags": [book_only, shared]}, headers=admin_headers) + client.patch(f"/api/maps/{m.id}", json={"tags": [shared]}, headers=admin_headers) + + tags = client.get("/api/tags", headers=admin_headers).json()["tags"] + by = {t["internal"]: t["category"] for t in tags} + assert by[book_only.lower()] == "book" + assert by[shared.lower()] == "shared" + + def test_counts_reflect_usage(self, client, admin_headers): + label = f"Count{uuid.uuid4().hex[:6]}" + _tag_a_map(client, admin_headers, [label]) + _tag_a_map(client, admin_headers, [label]) + maps = client.get("/api/tags?in_use_by=map", headers=admin_headers).json() + row = next(t for t in maps["tags"] if t["internal"] == label.lower()) + assert row["count"] == 2 + + +class TestTagItems: + def test_items_for_unknown_tag_404(self, client, admin_headers): + resp = client.get("/api/tags/no-such-tag-xyz/items", headers=admin_headers) + assert resp.status_code == 404 + + def test_items_filtered_by_resource_type(self, client, admin_headers): + system = make_game_system() + book = make_book(system.id) + label = f"Both{uuid.uuid4().hex[:6]}" + _tag_a_map(client, admin_headers, [label]) + client.patch(f"/api/books/{book.id}", json={"tags": [label]}, headers=admin_headers) + resp = client.get( + f"/api/tags/{label.lower()}/items?resource_type=book", headers=admin_headers + ) + items = resp.json()["items"] + assert all(i["item_type"] == "book" for i in items) + assert len(items) == 1 + + +class TestMutationPermissions: + def test_player_cannot_rename_tag(self, client, admin_headers, player_headers): + label = f"Perm{uuid.uuid4().hex[:6]}" + _tag_a_map(client, admin_headers, [label]) + resp = client.patch( + f"/api/tags/{label.lower()}", json={"display": "Renamed"}, headers=player_headers + ) + assert resp.status_code == 403 + + def test_admin_can_rename_display_same_key(self, client, admin_headers): + # A rename that only changes casing keeps the internal key. + label = f"Rename{uuid.uuid4().hex[:6]}" + _tag_a_map(client, admin_headers, [label]) + resp = client.patch( + f"/api/tags/{label.lower()}", + json={"display": label.upper()}, + headers=admin_headers, + ) + assert resp.status_code == 200 + assert resp.json()["display"] == label.upper() + assert resp.json()["internal"] == label.lower() + + def test_rename_rekeys_internal_on_typo_fix(self, client, admin_headers): + # Renaming to a different word re-keys the internal so search-by-internal + # finds the corrected value (e.g. "Freinds" → "Friends"). + _tag_a_map(client, admin_headers, ["Freinds"]) + resp = client.patch( + "/api/tags/freinds", + json={"display": "Friends"}, + headers=admin_headers, + ) + assert resp.status_code == 200 + assert resp.json()["display"] == "Friends" + assert resp.json()["internal"] == "friends" + # The old key no longer resolves. + gone = client.get("/api/tags/freinds/items", headers=admin_headers) + assert gone.status_code == 404 + + +class TestMergeAndDelete: + def test_merge_repoints_links(self, client, admin_headers): + src = f"Src{uuid.uuid4().hex[:6]}" + dst = f"Dst{uuid.uuid4().hex[:6]}" + m = _tag_a_map(client, admin_headers, [src]) + resp = client.post( + f"/api/tags/{src.lower()}/merge", json={"into": dst}, headers=admin_headers + ) + assert resp.status_code == 200 + # Source tag is gone; the map now carries the destination tag. + assert client.get(f"/api/tags/{src.lower()}/items", headers=admin_headers).status_code == 404 + items = client.get(f"/api/tags/{dst.lower()}/items", headers=admin_headers).json() + assert any(i["item_id"] == m.id for i in items["items"]) + + def test_cannot_merge_into_self(self, client, admin_headers): + label = f"Self{uuid.uuid4().hex[:6]}" + _tag_a_map(client, admin_headers, [label]) + resp = client.post( + f"/api/tags/{label.lower()}/merge", json={"into": label}, headers=admin_headers + ) + assert resp.status_code == 400 + + def test_delete_removes_tag_and_links(self, client, admin_headers): + label = f"Del{uuid.uuid4().hex[:6]}" + _tag_a_map(client, admin_headers, [label]) + resp = client.delete(f"/api/tags/{label.lower()}", headers=admin_headers) + assert resp.status_code == 204 + assert client.get(f"/api/tags/{label.lower()}/items", headers=admin_headers).status_code == 404 + + +class TestListAllTags: + def test_list_all_includes_unscoped_tags_with_counts(self, client, admin_headers): + label = f"All{uuid.uuid4().hex[:6]}" + _tag_a_map(client, admin_headers, [label]) + resp = client.get("/api/tags", headers=admin_headers) + assert resp.status_code == 200 + row = next(t for t in resp.json()["tags"] if t["internal"] == label.lower()) + assert row["count"] == 1 + + +class TestCreateTag: + def test_create_tag_up_front(self, client, admin_headers): + label = f"Made{uuid.uuid4().hex[:6]}" + resp = client.post( + "/api/tags", json={"value": label, "display": "Made Nice"}, headers=admin_headers + ) + assert resp.status_code == 201 + assert resp.json() == { + "internal": label.lower(), + "display": "Made Nice", + "category": "shared", + } + + def test_create_blank_tag_returns_400(self, client, admin_headers): + resp = client.post("/api/tags", json={"value": " "}, headers=admin_headers) + assert resp.status_code == 400 + + def test_player_cannot_create(self, client, player_headers): + resp = client.post("/api/tags", json={"value": "nope"}, headers=player_headers) + assert resp.status_code == 403 + + +class TestValidationEdges: + def test_tag_items_invalid_resource_type_400(self, client, admin_headers): + label = f"Edge{uuid.uuid4().hex[:6]}" + _tag_a_map(client, admin_headers, [label]) + resp = client.get( + f"/api/tags/{label.lower()}/items?resource_type=widget", headers=admin_headers + ) + assert resp.status_code == 400 + + def test_rename_blank_display_400(self, client, admin_headers): + label = f"Blank{uuid.uuid4().hex[:6]}" + _tag_a_map(client, admin_headers, [label]) + resp = client.patch( + f"/api/tags/{label.lower()}", json={"display": " "}, headers=admin_headers + ) + assert resp.status_code == 400 + + def test_rename_unknown_tag_404(self, client, admin_headers): + resp = client.patch( + "/api/tags/no-such-xyz", json={"display": "X"}, headers=admin_headers + ) + assert resp.status_code == 404 + + def test_merge_unknown_source_404(self, client, admin_headers): + resp = client.post( + "/api/tags/no-such-xyz/merge", json={"into": "anything"}, headers=admin_headers + ) + assert resp.status_code == 404 + + def test_delete_unknown_tag_404(self, client, admin_headers): + resp = client.delete("/api/tags/no-such-xyz", headers=admin_headers) + assert resp.status_code == 404 + + +class TestItemEnrichmentTypes: + def test_items_include_map_audio_and_system(self, client, admin_headers): + label = f"Multi{uuid.uuid4().hex[:6]}" + system = make_game_system() + m = make_map() + a = make_audio() + client.patch(f"/api/systems/{system.id}", json={"tags": [label]}, headers=admin_headers) + client.patch(f"/api/maps/{m.id}", json={"tags": [label]}, headers=admin_headers) + client.patch(f"/api/audio/{a.id}", json={"tags": [label]}, headers=admin_headers) + items = client.get(f"/api/tags/{label.lower()}/items", headers=admin_headers).json()[ + "items" + ] + by_type = {i["item_type"] for i in items} + assert {"system", "map", "audio"} <= by_type + + +class TestExplicitFiltering: + def test_explicit_token_hidden_from_restricted_user( + self, client, admin_headers, player_headers, player_id + ): + # Restrict the player from explicit content. + client.patch( + f"/api/users/{player_id}", json={"allow_explicit": False}, headers=admin_headers + ) + label = f"Explicit{uuid.uuid4().hex[:6]}" + tok = make_token(is_explicit=True) + client.patch(f"/api/tokens/{tok.id}", json={"tags": [label]}, headers=admin_headers) + # Admin sees it; restricted player does not. + admin_items = client.get(f"/api/tags/{label.lower()}/items", headers=admin_headers).json() + assert any(i["item_id"] == tok.id for i in admin_items["items"]) + player_items = client.get( + f"/api/tags/{label.lower()}/items", headers=player_headers + ).json() + assert all(i["item_id"] != tok.id for i in player_items["items"]) + + +class TestFolderTags: + """Folder tags (from tags.json / folder tagging) surface in the tags view even + though they live on the folder tables, not the shared-tag tables.""" + + def test_folder_tag_appears_in_listing(self, client, admin_headers): + label = f"Wetland{uuid.uuid4().hex[:6]}" + _map_in_folder_with_folder_tags(f"Swamps{uuid.uuid4().hex[:6]}", [label]) + listing = client.get("/api/tags?in_use_by=map", headers=admin_headers).json() + row = next((t for t in listing["tags"] if t["internal"] == label.lower()), None) + assert row is not None + assert row["count"] >= 1 + + def test_folder_tag_items_resolve_to_folder_contents(self, client, admin_headers): + label = f"Cave{uuid.uuid4().hex[:6]}" + m = _map_in_folder_with_folder_tags(f"Caves{uuid.uuid4().hex[:6]}", [label]) + resp = client.get(f"/api/tags/{label.lower()}/items", headers=admin_headers) + assert resp.status_code == 200 + data = resp.json() + # Folder-derived items appear as a folder group, listing the folder's items. + folder_items = [i for g in data["folders"] for i in g["items"]] + assert any(i["item_id"] == m.id for i in folder_items) + + def test_folder_only_tag_not_404(self, client, admin_headers): + # A tag that exists only as a folder tag still resolves (no 404). + label = f"Forest{uuid.uuid4().hex[:6]}" + _map_in_folder_with_folder_tags(f"Woods{uuid.uuid4().hex[:6]}", [label]) + resp = client.get(f"/api/tags/{label.lower()}/items", headers=admin_headers) + assert resp.status_code == 200 + assert resp.json()["display"] == label + + def test_shared_and_folder_tag_counts_combine(self, client, admin_headers): + # Same tag on a folder and directly on a different map → count reflects both. + label = f"Ruins{uuid.uuid4().hex[:6]}" + _map_in_folder_with_folder_tags(f"Ancient{uuid.uuid4().hex[:6]}", [label]) + _tag_a_map(client, admin_headers, [label]) + listing = client.get("/api/tags?in_use_by=map", headers=admin_headers).json() + row = next(t for t in listing["tags"] if t["internal"] == label.lower()) + assert row["count"] >= 2 + + def test_delete_folder_only_tag_strips_folder_association(self, client, admin_headers): + # Deleting a folder-derived tag removes it from the folder (no 404). + label = f"DelFolder{uuid.uuid4().hex[:6]}" + folder = f"Grotto{uuid.uuid4().hex[:6]}" + _map_in_folder_with_folder_tags(folder, [label, "keep"]) + resp = client.delete(f"/api/tags/{label.lower()}", headers=admin_headers) + assert resp.status_code == 204 + # The tag no longer appears; the sibling folder tag survives. + listing = client.get("/api/tags", headers=admin_headers).json()["tags"] + internals = {t["internal"] for t in listing} + assert label.lower() not in internals + assert "keep" in internals + + def test_rename_folder_only_tag_does_not_404(self, client, admin_headers): + # Renaming a folder-derived tag (no shared Tag row yet) must persist to + # the DB instead of 404ing; the display then survives a rescan. + label = f"FolderRen{uuid.uuid4().hex[:6]}" + folder = f"Vault{uuid.uuid4().hex[:6]}" + _map_in_folder_with_folder_tags(folder, [label.lower()]) + resp = client.patch( + f"/api/tags/{label.lower()}", + json={"display": f"{label} Pretty"}, + headers=admin_headers, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["display"] == f"{label} Pretty" + + def test_delete_missing_tag_still_404s(self, client, admin_headers): + resp = client.delete( + f"/api/tags/none-{uuid.uuid4().hex[:6]}", headers=admin_headers + ) + assert resp.status_code == 404 diff --git a/docs/api.md b/docs/api.md index f8bf0d8..f273a32 100644 --- a/docs/api.md +++ b/docs/api.md @@ -91,6 +91,7 @@ The credential-checking endpoints - `/api/auth/login`, `/api/auth/setup`, `/api/ |----------|--------|------|-------------| | `/api/stats` | GET | JWT **or** `X-API-Key` header | Counts, page totals, library size | | `/api/about` | GET | any (JWT) | Build info for the About dialog: `{version, commit_hash, python_version}`. Deliberately **not** exposed on `/api/stats`, so these details aren't readable via the `X-API-Key` fallback. | +| `/api/latest-release` | GET | any (JWT) | Latest published release for the update-available check: `{latest_version}` (or `null`). Proxies GitHub's releases API server-side (cached ~1h) so the browser makes a same-origin request that request blockers won't block. Returns `null` when `DISABLE_VERSION_CHECKING` is set or GitHub is unreachable. | | `/api/scan-status` | GET | admin | Current scan state. `phase` is `scanning` (file walk), `indexing` (text-layer extraction), or `ocr` (deferred OCR of scanned/image-only PDFs). During the `ocr` phase, `total_ocr`/`ocr_done`/`ocr_current` report the OCR queue's progress. | | `/api/rescan` | POST | admin | Trigger a background rescan and reindex (optionally scoped, with a metadata-refresh mode) | | `/api/cancel-scan` | POST | admin | Request a graceful stop of the running scan or indexing job | @@ -209,21 +210,27 @@ Returns `{"status": "not_running"}` if no scan is in progress. Cancellation is c | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| -| `/api/systems` | GET | any | List all systems with book counts | -| `/api/systems/:id` | GET | any | System detail + full book list | +| `/api/systems` | GET | any | List all systems with book counts, `total_page_count`, and metadata. Query: `sort` (`name`\|`book_count`\|`page_count`\|`year`), `order` (`asc`\|`desc`), `genre`, `family`, `parent_system`, `edition`, `license`, `explicit` (bool) | +| `/api/systems/:id` | GET | any | System detail + full book list. Query: `book_sort` (`category`\|`title`\|`page_count`\|`year`), `book_order`, `explicit` (bool), `genre`, `category` filter the returned books | | `/api/systems/:id` | PATCH | gm/admin | Update metadata (see fields below) | -**PATCH fields:** `name`, `slug`, `description`, `publishers`, `character_builder_url`, `cover_image`, `cover_book_id`, `tags`, `genre`, `is_explicit` +**PATCH fields:** `name`, `slug`, `description`, `publishers`, `character_builder_url` (legacy), `character_builder_urls`, `urls`, `cover_image`, `cover_book_id`, `tags`, `genre` (legacy), `genres`, `dice_materials`, `system_family`, `parent_system`, `edition`, `license`, `year`, `is_explicit` **Publishers format:** `[{"name": "Publisher Name", "url": "https://..."}]` +**Link-list format** (`urls`, `character_builder_urls`): `[{"label": "DriveThruRPG", "url": "https://..."}]` + +**Multi-value metadata** (issue #202): `genres` and `dice_materials` are string arrays; `genres` supersedes the legacy single `genre`, and `urls`/`character_builder_urls` supersede the legacy single-URL fields (the legacy fields remain accepted for backward compatibility). Systems in the special one-page collection carry `is_one_page: true` (grouped with `is_system_agnostic` in the library UI). + +**Parent system / edition:** `parent_system` (e.g. `"Dungeons & Dragons"`) is the mid-tier grouping between the broad `system_family` (`"d20 System"`) and a concrete system; `edition` (`"5e"`, `"Red"`, `"2020"`) combines with it for display (`"Cyberpunk Red"`). Both are free-text; `parent_system` values are curated via the `/api/parent-systems` lookup. Both are filterable on `/api/systems`. + ### Books | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/books` | GET | any | Paginated book list. Query: `system_id`, `category`, `limit` (max 500, default 100), `offset` | | `/api/books/:id` | GET | any | Book detail with game system | -| `/api/books/:id` | PATCH | gm/admin | Update: `title`, `category`, `description`, `authors`, `publisher`, `publisher_url`, `year`, `is_explicit` | +| `/api/books/:id` | PATCH | gm/admin | Update: `title`, `category`, `description`, `authors`, `artists`, `genres`, `publisher`, `publisher_url` (legacy), `urls`, `isbn`, `version`, `language`, `license`, `year`, `month` (1–12), `day` (1–31), `tags`, `is_explicit`. `license` overrides the system license for this book (blank inherits it). `file_size`/`page_count`/`mime_type` are read-only. | | `/api/books/:id/reindex` | POST | gm/admin | Re-run OCR on a scanned book. Optional query `ocr_dpi` (72–600) re-reads this book at a higher resolution than the global `OCR_DPI`; omit for the default. Clears the book's search index and re-queues it (OCR runs in the background — poll `/api/scan-status`). 400 if the book has an embedded text layer (nothing to OCR). Returns `{status: "reindex_queued", ocr_dpi}`. | | `/api/books/:id/rescan` | POST | gm/admin | Re-read a single book from disk and rebuild its search index, for a file edited externally. Unlike `/reindex` this works for any PDF: a text-layer book is re-extracted and its FTS rows rebuilt; an image-only book is re-queued for OCR. Refreshes page count and cover thumbnail if the file changed. Runs in the background (poll `/api/scan-status`); no-ops if a library scan is already running. 400 for non-PDFs, 404 if the file is missing on disk. Returns `{status: "rescan_queued"}`. | | `/api/books/:id/file` | GET | any | Download/stream the file | @@ -239,6 +246,34 @@ Returns `{"status": "not_running"}` if no scan is in progress. Cancellation is c **Categories:** `core`, `supplement`, `adventure`, `character-sheet`, `map`, `handout`, `homebrew`, `starter-set` +### Metadata lookups (genres, families, parent systems, licenses, dice/materials) + +Curated reference values that power the editor pickers/comboboxes and the +"Metadata" settings tab (issue #202). Reads are open to any authenticated user; +mutations require admin. Every list is managed in **Settings → Metadata**, where +each section is collapsible. + +| Endpoint | Method | Auth | Description | +|----------|--------|------|-------------| +| `/api/genres` | GET | any | `{"genres": [{id, name, parent_id, is_default, sort_order}]}`. Tiered via `parent_id` (e.g. Cyberpunk → Science Fiction). | +| `/api/genres` | POST | admin | Create a genre. Body `{name, parent_id?}`. 409 if the name exists. | +| `/api/genres/:id` | DELETE | admin | Delete a genre (and its children). 409 with `{detail: {message, name, usage_count}}` if attached to a system/book, unless `?force=true`. | +| `/api/system-families` | GET | any | `{"families": [{id, name, is_default, sort_order}]}` | +| `/api/system-families` | POST | admin | Create a family. Body `{name}`. 409 if the name exists. | +| `/api/system-families/:id` | DELETE | admin | Delete a family. 409 if in use unless `?force=true`. | +| `/api/parent-systems` | GET | any | `{"parent_systems": [{id, name, is_default, sort_order}]}`. Empty by default (library-specific). | +| `/api/parent-systems` | POST | admin | Create a parent system. Body `{name}`. 409 if the name exists. | +| `/api/parent-systems/:id` | DELETE | admin | Delete a parent system. 409 if in use unless `?force=true`. | +| `/api/licenses` | GET | any | `{"licenses": [{id, name, is_default, sort_order}]}`. Seeded with common TTRPG licenses (OGL, ORC, CC-BY, Proprietary, …). | +| `/api/licenses` | POST | admin | Create a license. Body `{name}`. 409 if the name exists. | +| `/api/licenses/:id` | DELETE | admin | Delete a license. 409 if used by a system or book unless `?force=true`. | +| `/api/dice-materials` | GET | any | `{"dice_materials": [{id, name, group, is_default, sort_order}]}`. `group` is one of `Dice`\|`Cards`\|`Other`\|`Custom`. Sources the editor's dice/materials picker options. | +| `/api/dice-materials` | POST | admin | Create a dice/material. Body `{name, group?}` (defaults to `Custom`). 409 if the name exists. The editor picker best-effort POSTs here (as group `Custom`) when an admin types a new value, so it becomes reusable. | +| `/api/dice-materials/:id` | DELETE | admin | Delete a dice/material. 409 if in use unless `?force=true`. | + +Defaults for both tables are seeded on migration and are removable. A genre or +family removed while attached to systems/books is detached from them (`?force=true`). + ### Maps | Endpoint | Method | Auth | Description | @@ -288,7 +323,65 @@ Audio tracks behave like maps/tokens, with embedded metadata. Supported formats: | `/api/favorites` | POST | any | Add a favorite (idempotent). Body: `{item_type, item_id}` | | `/api/favorites/:type/:id` | DELETE | any | Remove a favorite (silent 204 if not found) | -Item types: `book`, `map`, `token`, `audio`, `system` +Item types: `book`, `map`, `token`, `audio`, `system`, `tag` (a `tag` favorite's +`item_id` is the tag's internal key; it enriches to `{internal, display, count}`). + +### Tags + +Application-wide tags shared across systems, books, maps, tokens, and audio. Each +tag has a lowercased **internal** key (used for matching/dedup) and an editable +**display** value (the casing first entered). The internal key normally stays put, +but a rename that changes the display's normalized form re-keys it (a typo fix like +`freinds` → `friends`), merging into an existing tag if one already owns the new key. +A resource's tags are set through its own update endpoint (e.g. `PATCH /api/books/:id` +with `tags`); these endpoints manage the shared tag catalog and browse items by tag. + +| Endpoint | Method | Auth | Description | +|----------|--------|------|-------------| +| `/api/tags` | GET | any | List tags with usage `count` and `is_favorite` (for the current user). Query `in_use_by=system\|book\|map\|token\|audio` restricts to tags used on that resource type. Folder tags (from `tags.json`/folder tagging, including **book subcategory folders**) are merged in and counted by the items they cover | +| `/api/tags` | POST | gm/admin | Create a tag up front (idempotent by internal key). Body: `{value, display?}` | +| `/api/tags/:internal/items` | GET | any | Items carrying the tag: `items` (directly tagged, enriched like favorites) plus `folders` (folder-derived — each `{resource_type, path, items}` lists the whole folder's contents; book folders show only their subfolder path). Query `resource_type=` filters by type. Explicit items are hidden from users who can't see them | +| `/api/tags/:internal` | PATCH | gm/admin | Rename a tag's display value; when the new display normalizes to a different key the internal is re-keyed too (merging into an existing tag on collision). Works for **folder-only** tags too (a tag that lives only in folder JSON is materialised into a catalog row so the rename persists — no 404). Body: `{display}` | +| `/api/tags/:internal/merge` | POST | gm/admin | Merge this tag into another, re-pointing all links. Body: `{into}` | +| `/api/tags/:internal` | DELETE | gm/admin | Delete a tag and unlink it from every resource | + +Tag object shape: `{internal, display, category}` (list/item endpoints also include +`count`; the list adds `is_favorite`). A tag's **category** is the single resource +type it is used on (`system`/`book`/`map`/`token`/`audio`), or `shared` once it spans +more than one type. The reported category is *effective*: it reconciles the tag's +stored category (direct usage) with every resource type it appears on via folder tags, +so a tag on a book plus a map folder resolves to `shared` even though folder tags never +rewrite the stored row. Migrated tag displays default to Title Case; users favorite +tags via the favorites endpoints (`item_type: "tag"`). Resource types: `system`, +`book`, `map`, `token`, `audio`. + +Book subcategory folders are tagged via `PATCH /api/systems/:id/book-folders` (path +`{system_id}/{category}/{subfolder…}`) and surface on the tags page under **Books**, +alongside the `tags.json`-style folder tags on maps/tokens/audio. + +**Folder tags** (the JSON `tags` lists on `*_folders`/`book_folders`) store tag +**internal keys**; their display casing comes from the tag catalog (folder read +endpoints resolve them back to display strings). Saving folder tags registers a +catalog row for each (new tags take the entered casing as their default display; +existing tags keep theirs). Because the library is read-only, `tags.json` is an +**additive** input: on rescan it only *adds* tags — it never removes a folder or +item tag set/removed in the UI, and never overwrites an existing tag's display. + +### Saved filters + +Per-user named sort/filter presets for a library scope. At most one preset per +(user, scope) may be the **default** — the view the user lands on. Setting a +preset default clears the flag on any sibling in the same scope. + +| Endpoint | Method | Auth | Description | +|----------|--------|------|-------------| +| `/api/saved-filters` | GET | any | List the user's saved filters. Optional query `scope` limits to one scope. Returns `{filters: [{id, scope, name, state, is_default}]}` | +| `/api/saved-filters` | POST | any | Create a preset. Body `{scope, name, state, is_default?}`. Re-saving an existing `(scope, name)` overwrites its `state`. | +| `/api/saved-filters/:id` | PATCH | any | Rename, replace `state`, and/or set as the scope default. Body `{name?, state?, is_default?}` | +| `/api/saved-filters/:id` | DELETE | any | Delete one of the user's saved filters | + +Scopes: `systems`, `books`, `maps`, `tokens`, `audio`. `state` is an opaque +sort/filter object the client interprets (e.g. `{sort, order, filters}`). ### Bookmarks diff --git a/docs/data-model.md b/docs/data-model.md index e72682e..51523ec 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -23,6 +23,7 @@ erDiagram users ||--o{ campaign_members : "member of" users ||--o{ bookmarks : has users ||--o{ favorites : has + users ||--o{ saved_filters : has users ||--o{ session_availability : declares users ||--o{ player_session_notes : writes users ||--o{ wiki_pages : "created by" @@ -33,6 +34,8 @@ erDiagram game_systems ||--o{ books : contains game_systems ||--o{ campaigns : "system for" + genres ||--o{ genres : "parent of" + books ||--o{ bookmarks : "bookmarked in" campaigns ||--o{ campaign_members : has @@ -57,20 +60,25 @@ erDiagram wiki_pages ||--o{ wiki_pages : "parent of" wiki_pages ||--o{ wiki_page_links : "source of" wiki_pages ||--o{ wiki_page_links : "target of" + + tags ||--o{ resource_tags : "applied via" ``` ## Foreign keys -There are 32 `ForeignKey` declarations across the models, plus the two self-referential -keys (`campaigns.parent_campaign_id`, `wiki_pages.parent_id`) and the polymorphic soft -links from `campaign_resources`/`favorites`, which are *not* declared foreign keys. +There are 34 `ForeignKey` declarations across the models, plus the three self-referential +keys (`campaigns.parent_campaign_id`, `wiki_pages.parent_id`, `genres.parent_id`) and the +polymorphic soft links from `campaign_resources`/`favorites`/`resource_tags`, which are +*not* declared foreign keys. | From (table.column) | To (table.column) | Notes | | --- | --- | --- | | `books.game_system_id` | `game_systems.id` | nullable; a book may be unassigned | +| `genres.parent_id` | `genres.id` | self-referential; nullable (tiered genres) | | `bookmarks.user_id` | `users.id` | | | `bookmarks.book_id` | `books.id` | | | `favorites.user_id` | `users.id` | `item_id` is a soft link (not a FK) | +| `saved_filters.user_id` | `users.id` | per-user sort/filter presets | | `campaigns.owner_id` | `users.id` | the GM / creator | | `campaigns.parent_campaign_id` | `campaigns.id` | self-referential; nullable | | `campaigns.system_id` | `game_systems.id` | nullable; falls back to `system_name` | @@ -99,6 +107,7 @@ links from `campaign_resources`/`favorites`, which are *not* declared foreign ke | `wiki_page_links.campaign_id` | `campaigns.id` | | | `wiki_page_links.source_page_id` | `wiki_pages.id` | | | `wiki_page_links.target_page_id` | `wiki_pages.id` | | +| `resource_tags.tag_id` | `tags.id` | `ON DELETE CASCADE`; `resource_id` is a polymorphic soft link (not a FK) | ## Table reference @@ -106,9 +115,14 @@ links from `campaign_resources`/`favorites`, which are *not* declared foreign ke | Table | Purpose | Key columns / constraints | | --- | --- | --- | -| `game_systems` | A TTRPG system (D&D 5e, PbtA, …). | `name`, `slug` unique. `is_system_agnostic` flags cross-system content. | -| `books` | One PDF/document in the library. | `filepath` unique. `game_system_id` FK. Index `ix_books_indexer_queue` on `(indexed, mime_type)` drives the indexer. `indexed`/`index_failed`/`is_missing` track scan state. `index_error` holds the failure message, or the sentinel `image-only` (no text layer, OCR unavailable) / `ocr` (indexed via OCR). `ocr_pending` (indexed `ix_books_ocr_pending`) flags a scanned PDF queued for deferred OCR; `ocr_pages_done` is the per-page OCR checkpoint so a long book resumes rather than restarts after an interruption. `ocr_dpi` is an optional per-book OCR resolution override (NULL = global `OCR_DPI`), set when a book is re-OCR'd at a higher DPI via `POST /api/books/{id}/reindex`. | -| `book_folders` | Tags auto-applied to a book subcategory folder path. | `path` unique. | +| `game_systems` | A TTRPG system (D&D 5e, PbtA, …). | `name`, `slug` unique. `is_system_agnostic` flags cross-system content; `is_one_page` flags the special one-page/small-RPG collection (both grouped together in the library UI). Metadata (issue #202): `genres` (JSON list; supersedes the legacy scalar `genre`), `dice_materials` (JSON list), `system_family`, `parent_system` (mid-tier grouping, e.g. "Dungeons & Dragons"), `edition` (e.g. "5e"/"Red"; combines with `parent_system` for display), `license`, `year`, `urls` and `character_builder_urls` (JSON lists of `{label, url}`; supersede the legacy scalar `character_builder_url`). | +| `books` | One PDF/document in the library. | `filepath` unique. `game_system_id` FK. Index `ix_books_indexer_queue` on `(indexed, mime_type)` drives the indexer. `indexed`/`index_failed`/`is_missing` track scan state. `index_error` holds the failure message, or the sentinel `image-only` (no text layer, OCR unavailable) / `ocr` (indexed via OCR). `ocr_pending` (indexed `ix_books_ocr_pending`) flags a scanned PDF queued for deferred OCR; `ocr_pages_done` is the per-page OCR checkpoint so a long book resumes rather than restarts after an interruption. `ocr_dpi` is an optional per-book OCR resolution override (NULL = global `OCR_DPI`), set when a book is re-OCR'd at a higher DPI via `POST /api/books/{id}/reindex`. Metadata (issue #202): `artists` and `genres` (JSON lists), `isbn`, `version`, `language`, `license` (per-book override of the system license — an OGL SRD inside a proprietary system), `urls` (JSON list of `{label, url}`; supersedes the legacy scalar `publisher_url`), and a variable-precision publication date `year`/`month`/`day` (all nullable — `year` may stand alone). | +| `book_folders` | Folder tags for a book subcategory folder path (`{system_id}/{category}/{subfolder…}`), editable inline on the system page and surfaced on the tags page under Books. `tags` stores internal keys (display comes from the `tags` catalog); `tags.json` applies additively. | `path` unique. | +| `genres` | Curated genre lookup, tiered via a self-referential `parent_id` (e.g. Cyberpunk → Science Fiction). | `name` unique. `is_default` marks seeded rows; `sort_order` orders siblings. Children cascade-delete. | +| `system_families` | Curated system-family / engine lookup (PbtA, d20, Year Zero, …). | `name` unique. `is_default`, `sort_order`. | +| `parent_systems` | Curated parent-system lookup — the mid tier between a `system_family` and a concrete system (e.g. "Dungeons & Dragons"). | `name` unique. `is_default`, `sort_order`. Seeded empty. | +| `licenses` | Curated license lookup (OGL, ORC, CC-BY, Proprietary, …), used by systems and per-book overrides. | `name` unique. `is_default`, `sort_order`. | +| `dice_materials` | Curated dice / materials lookup for the system picker. | `name` unique. `group` (`Dice`\|`Cards`\|`Other`\|`Custom`). `is_default`, `sort_order`. | ### Media - [`backend/models/media.py`](../backend/models/media.py) @@ -120,7 +134,7 @@ None of these tables carry foreign keys; they are linked to campaigns polymorphi | `generic_maps` | A map image not tied to a system. | `filepath` unique. | | `tokens` | A token image for use on maps. | `filepath` unique. `is_explicit` gates by user preference. | | `audio` | An audio track. | `filepath` unique. Embedded metadata (`title`, `artist`, `duration`, …) populated by the indexer. | -| `map_folders`, `token_folders`, `audio_folders` | Tags auto-applied to a media folder path. | `path` unique. | +| `map_folders`, `token_folders`, `audio_folders` | Folder tags for a media folder path. `tags` is a JSON list of tag **internal keys**; display casing comes from the `tags` catalog. `tags.json` applies additively (adds keys, never removes/overwrites) since the library is read-only. | `path` unique. | ### Users - [`backend/models/users.py`](../backend/models/users.py) @@ -129,6 +143,25 @@ None of these tables carry foreign keys; they are linked to campaigns polymorphi | `users` | An authenticated account. | `username` unique; `email`, `opds_token`, `oidc_subject` unique + indexed. `role` ∈ `admin`/`gm`/`player`/`guest`. `is_guest` marks campaign-scoped guest accounts. | | `bookmarks` | Per-user page/text bookmark in a book. | FKs `user_id`, `book_id`. Index `ix_bookmarks_user_book` on `(user_id, book_id)`. | | `favorites` | Per-user favorite across books/maps/tokens. | FK `user_id`. Polymorphic `(item_type, item_id)`. **Unique** `(user_id, item_type, item_id)`. | +| `saved_filters` | Per-user named sort/filter preset for a library scope. | FK `user_id` (indexed). `scope` ∈ systems/books/maps/tokens/audio. `state` JSON holds the sort/filter object. `is_default` marks the per-scope landing view (at most one per scope, enforced in the router). **Unique** `(user_id, scope, name)`. | + +### Tags - [`backend/models/tags.py`](../backend/models/tags.py) + +Application-wide tags shared across systems, books, maps, tokens, and audio (issue +#235). Item tags are written through each resource's own update endpoint, which +mirrors them into these tables via `backend/services/tag_service.py`. (Folder-level +tags on `*_folders` remain JSON on those rows and are a separate concept.) + +| Table | Purpose | Key columns / constraints | +| --- | --- | --- | +| `tags` | A shared tag. | `internal` unique (lowercased match key; usually stable, but a rename that changes the display's normalized form re-keys it, merging on collision). `display` (human casing, editable on the tags page). `category` — the single resource type it belongs to (`system`/`book`/`map`/`token`/`audio`) or `shared` once used across more than one type (auto-promoted by the tag service). The tags API reports an *effective* category that also factors in folder-tag usage (incl. book folders), so a stored `book` tag that also appears on a map folder is reported as `shared`. | +| `resource_tags` | Polymorphic link between a tag and a tagged resource. | FK `tag_id` (`ON DELETE CASCADE`). Polymorphic `(resource_type, resource_id)` where `resource_type` ∈ system/book/map/token/audio. **Unique** `(tag_id, resource_type, resource_id)`. Indexes `ix_resource_tags_resource` on `(resource_type, resource_id)` and `ix_resource_tags_tag` on `tag_id`. | + +These tables are the sole source of item tags: the legacy per-row JSON `tags` +columns on `game_systems`/`books`/`generic_maps`/`tokens`/`audio` were backfilled +into them (migration 0008) and then dropped (migration 0009). Folder tables +(`*_folders`) still keep their own JSON `tags` — folder tagging is a separate, +path-keyed feature. ### Campaigns - [`backend/models/campaigns.py`](../backend/models/campaigns.py) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d6d7c33..f695c63 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2130,9 +2130,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz", + "integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==", "dev": true, "license": "MIT", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index b9424ba..cb97223 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -41,5 +41,8 @@ "prettier": "^3.8.3", "vite": "^8.0.16", "vitest": "^4.1.2" + }, + "overrides": { + "brace-expansion": "^1.1.16" } } diff --git a/frontend/src/api.js b/frontend/src/api.js index 2a3fadf..45a6546 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -184,6 +184,21 @@ export const opds = { revokeToken: () => api.delete('/users/me/opds'), } +export const tags = { + // in_use_by scopes the list to tags used on a resource type (with counts). + list: (inUseBy) => api.get(`/tags${inUseBy ? `?in_use_by=${encodeURIComponent(inUseBy)}` : ''}`), + items: (internal, resourceType) => + api.get( + `/tags/${encodeURIComponent(internal)}/items${ + resourceType ? `?resource_type=${encodeURIComponent(resourceType)}` : '' + }` + ), + create: (value, display) => api.post('/tags', { value, display }), + rename: (internal, display) => api.patch(`/tags/${encodeURIComponent(internal)}`, { display }), + merge: (internal, into) => api.post(`/tags/${encodeURIComponent(internal)}/merge`, { into }), + remove: (internal) => api.delete(`/tags/${encodeURIComponent(internal)}`), +} + export const settings = { get: () => api.get('/settings'), getUi: () => api.get('/settings/ui'), diff --git a/frontend/src/components/AppShell.jsx b/frontend/src/components/AppShell.jsx index bc7ebb7..227d769 100644 --- a/frontend/src/components/AppShell.jsx +++ b/frontend/src/components/AppShell.jsx @@ -20,6 +20,7 @@ import AudioDetailView from './audio/AudioDetailView' import SearchView from '../views/SearchView' import SettingsView from '../views/SettingsView' import FavoritesView from '../views/FavoritesView' +import TagsView from '../views/TagsView' import CampaignsView from '../views/CampaignsView' import CampaignDetailView from '../views/CampaignDetailView' import CampaignNotesView from '../views/CampaignNotesView' @@ -146,6 +147,7 @@ export default function AppShell() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/BulkEditModal.jsx b/frontend/src/components/BulkEditModal.jsx index aab23a0..32e7f4e 100644 --- a/frontend/src/components/BulkEditModal.jsx +++ b/frontend/src/components/BulkEditModal.jsx @@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next' import { LuX, LuChevronLeft, LuChevronRight } from 'react-icons/lu' import api from '../api' import SystemBulkEditFields from './system/SystemBulkEditFields' +import BookBulkEditFields from './system/BookBulkEditFields' +import { cleanLinks } from './metadata/metadataUtils' // Per-type editable fields and the PATCH endpoint they save to. Tags are edited // as a comma-separated string and split on save. @@ -21,19 +23,45 @@ const CONFIG = { }, book: { endpoint: (id) => `/books/${id}`, - fields: ['title', 'category', 'description', 'publisher', 'year', 'tags', 'is_explicit'], + // Books use a bespoke editor body (BookBulkEditFields) mirroring the full + // single-book editor, so genres/tags/authors/artists/links stay native + // arrays and category uses the shared combobox. + fields: [ + 'title', + 'description', + 'category', + 'genres', + 'tags', + 'urls', + 'authors', + 'artists', + 'publisher', + 'isbn', + 'version', + 'language', + 'year', + 'month', + 'day', + 'is_explicit', + ], + custom: true, }, system: { endpoint: (id) => `/systems/${id}`, - // Systems use a bespoke editor body (SystemBulkEditFields) rather than the - // generic field loop, so tags/publishers stay native arrays and the cover - // image can be picked from each system's own books. + // Systems use a bespoke editor body (SystemBulkEditFields) that mirrors the + // full single-system editor, so tags/publishers/genres/links stay native + // arrays and the cover image can be picked from each system's own books. fields: [ 'description', 'tags', + 'genres', + 'dice_materials', + 'system_family', + 'license', + 'year', 'publishers', - 'character_builder_url', - 'genre', + 'urls', + 'character_builder_urls', 'is_explicit', 'cover_book_id', ], @@ -43,7 +71,15 @@ const CONFIG = { // Fields that are stored as arrays/objects rather than strings — kept as native // values in the draft (not stringified) and compared by JSON on save. -const STRUCTURED_FIELDS = new Set(['publishers']) +const STRUCTURED_FIELDS = new Set([ + 'publishers', + 'genres', + 'dice_materials', + 'urls', + 'character_builder_urls', + 'authors', + 'artists', +]) // Pull a grid size like "22x22" out of a map's filename or folder, e.g. // "Sunken Temple (22x22)" → "22x22". Used to pre-fill an empty grid size. @@ -56,6 +92,15 @@ const inferGridSize = (item) => { return '' } +// Normalize a structured field's draft value before compare/save. +const cleanStructured = (field, value) => { + const list = value || [] + if (field === 'publishers') return list.filter((p) => p.name?.trim()) + if (field === 'urls' || field === 'character_builder_urls') return cleanLinks(list) + // genres / dice_materials are plain string arrays, already trimmed in the UI. + return list +} + const tagsToString = (tags) => (Array.isArray(tags) ? tags.join(', ') : '') const stringToTags = (s) => s @@ -69,7 +114,14 @@ const stringToTags = (s) => * via its single-item PATCH endpoint and calls `onSaved` with a map of * { id: changedFields } so the parent view can patch local state. */ -export default function BulkEditModal({ type, items, onClose, onSaved }) { +export default function BulkEditModal({ + type, + items, + onClose, + onSaved, + existingCategories = [], + systemGenres = [], +}) { const { t } = useTranslation() const cfg = CONFIG[type] const [index, setIndex] = useState(0) @@ -133,17 +185,16 @@ export default function BulkEditModal({ type, items, onClose, onSaved }) { const next = cfg.custom ? d.tags : stringToTags(d.tags) if (tagsToString(next) !== tagsToString(it.tags)) patch.tags = next } else if (STRUCTURED_FIELDS.has(f)) { - // Drop empty publisher rows before comparing/saving. - const next = (d[f] || []).filter((p) => p.name?.trim()) + const next = cleanStructured(f, d[f]) if (JSON.stringify(next) !== JSON.stringify(it[f] || [])) patch[f] = next } else if (f === 'is_explicit') { if (!!d.is_explicit !== !!it.is_explicit) patch.is_explicit = !!d.is_explicit } else if (f === 'cover_book_id') { if ((d.cover_book_id ?? null) !== (it.cover_book_id ?? null)) patch.cover_book_id = d.cover_book_id ?? null - } else if (f === 'year') { - const next = d.year === '' ? null : Number(d.year) - if (next !== (it.year ?? null)) patch.year = next + } else if (f === 'year' || f === 'month' || f === 'day') { + const next = d[f] === '' || d[f] == null ? null : Number(d[f]) + if (next !== (it[f] ?? null)) patch[f] = next } else if ((d[f] ?? '') !== (it[f] ?? '')) { patch[f] = d[f] } @@ -208,6 +259,13 @@ export default function BulkEditModal({ type, items, onClose, onSaved }) { {cfg.custom && type === 'system' ? ( + ) : cfg.custom && type === 'book' ? ( + ) : (
{cfg.fields.map((f) => { diff --git a/frontend/src/components/BulkEditModal.test.jsx b/frontend/src/components/BulkEditModal.test.jsx index f87988e..70c4f47 100644 --- a/frontend/src/components/BulkEditModal.test.jsx +++ b/frontend/src/components/BulkEditModal.test.jsx @@ -3,9 +3,20 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react' import BulkEditModal from './BulkEditModal' const patch = vi.fn(() => Promise.resolve({})) -const get = vi.fn(() => Promise.resolve({ books: [] })) +const post = vi.fn(() => Promise.resolve({ id: 'g1', name: 'Fantasy' })) +// useLookups fetches /genres and /system-families; return empty lookup lists. +const get = vi.fn((path) => { + if (path?.includes('genres')) return Promise.resolve({ genres: [] }) + if (path?.includes('system-families')) return Promise.resolve({ families: [] }) + return Promise.resolve({ books: [] }) +}) vi.mock('../api', () => ({ - default: { patch: (...args) => patch(...args), get: (...args) => get(...args) }, + default: { + patch: (...args) => patch(...args), + get: (...args) => get(...args), + post: (...args) => post(...args), + }, + tags: { list: () => Promise.resolve({ tags: [] }) }, mediaUrl: (p) => p, })) @@ -53,23 +64,27 @@ describe('BulkEditModal', () => { expect(onSaved).toHaveBeenCalledWith({ m1: { tags: ['old', 'new'] } }) }) - it('edits a system genre via the /systems endpoint', async () => { + it('edits system genres via the genre combobox', async () => { const onSaved = vi.fn() // Seed `books` so the cover picker doesn't lazy-fetch. const systems = [ - { id: 's1', name: 'Alpha', tags: ['osr'], genre: '', is_explicit: false, books: [] }, + { id: 's1', name: 'Alpha', tags: ['osr'], genres: [], is_explicit: false, books: [] }, ] render() expect(screen.getByText('Alpha')).toBeInTheDocument() - // Genre is a labelled field in the rich system editor. - fireEvent.change(screen.getByLabelText('Genre'), { target: { value: 'Fantasy' } }) + // Genres use the shared GenrePicker combobox (aria-label "Add genre"): type + // then pick the create row. + const combo = screen.getByRole('combobox', { name: /add genre/i }) + fireEvent.change(combo, { target: { value: 'Fantasy' } }) + fireEvent.click(await screen.findByRole('option', { name: /Fantasy/ })) + fireEvent.click(screen.getByText('Save all')) await waitFor(() => expect(onSaved).toHaveBeenCalled()) - expect(patch).toHaveBeenCalledWith('/systems/s1', { genre: 'Fantasy' }) - expect(onSaved).toHaveBeenCalledWith({ s1: { genre: 'Fantasy' } }) + expect(patch).toHaveBeenCalledWith('/systems/s1', { genres: ['Fantasy'] }) + expect(onSaved).toHaveBeenCalledWith({ s1: { genres: ['Fantasy'] } }) }) it('edits system description, publishers, and explicit flag', async () => { diff --git a/frontend/src/components/BulkToggleButton.jsx b/frontend/src/components/BulkToggleButton.jsx new file mode 100644 index 0000000..02c4cec --- /dev/null +++ b/frontend/src/components/BulkToggleButton.jsx @@ -0,0 +1,22 @@ +import { useTranslation } from 'react-i18next' +import { LuListChecks } from 'react-icons/lu' +import ToolbarButton from './ToolbarButton' + +/** + * Shared "Select" / "Cancel" toggle used on every library-style page. + * Keeps a stable width regardless of state (the label swaps between two words) + * and standardizes the off-state label to "Cancel" across all pages. + */ +export default function BulkToggleButton({ active, onToggle, style }) { + const { t } = useTranslation() + return ( + } + label={active ? t('common.cancel') : t('common.select')} + onClick={onToggle} + active={active} + minWidth={96} + style={style} + /> + ) +} diff --git a/frontend/src/components/BulkToggleButton.test.jsx b/frontend/src/components/BulkToggleButton.test.jsx new file mode 100644 index 0000000..ed581fe --- /dev/null +++ b/frontend/src/components/BulkToggleButton.test.jsx @@ -0,0 +1,31 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import BulkToggleButton from './BulkToggleButton' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (k) => ({ 'common.select': 'Select', 'common.cancel': 'Cancel' })[k] || k, + }), +})) + +describe('BulkToggleButton', () => { + it('shows "Select" when inactive and "Cancel" when active', () => { + const { rerender } = render( {}} />) + expect(screen.getByText('Select')).toBeInTheDocument() + rerender( {}} />) + expect(screen.getByText('Cancel')).toBeInTheDocument() + }) + + it('keeps a stable width across states (minWidth set)', () => { + render( {}} />) + expect(screen.getByText('Select').closest('button').style.minWidth).toBe('96px') + }) + + it('fires onToggle when clicked', async () => { + const onToggle = vi.fn() + render() + await userEvent.click(screen.getByText('Select')) + expect(onToggle).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/CollapseExpandButtons.jsx b/frontend/src/components/CollapseExpandButtons.jsx new file mode 100644 index 0000000..e48db94 --- /dev/null +++ b/frontend/src/components/CollapseExpandButtons.jsx @@ -0,0 +1,29 @@ +import { useTranslation } from 'react-i18next' +import ToolbarButton from './ToolbarButton' + +/** + * Shared "Collapse All" / "Expand All" pair used by the system detail and media + * gallery toolbars. Each button disables when the action would be a no-op. + */ +export default function CollapseExpandButtons({ + onCollapseAll, + onExpandAll, + collapseDisabled = false, + expandDisabled = false, +}) { + const { t } = useTranslation() + return ( + <> + + + + ) +} diff --git a/frontend/src/components/CollapseExpandButtons.test.jsx b/frontend/src/components/CollapseExpandButtons.test.jsx new file mode 100644 index 0000000..e79f06d --- /dev/null +++ b/frontend/src/components/CollapseExpandButtons.test.jsx @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import CollapseExpandButtons from './CollapseExpandButtons' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (k) => ({ 'common.collapseAll': 'Collapse All', 'common.expandAll': 'Expand All' })[k] || k, + }), +})) + +describe('CollapseExpandButtons', () => { + it('fires the collapse and expand callbacks', async () => { + const onCollapseAll = vi.fn() + const onExpandAll = vi.fn() + render() + await userEvent.click(screen.getByText('Collapse All')) + await userEvent.click(screen.getByText('Expand All')) + expect(onCollapseAll).toHaveBeenCalled() + expect(onExpandAll).toHaveBeenCalled() + }) + + it('disables each button independently', () => { + render( + {}} + onExpandAll={() => {}} + collapseDisabled + expandDisabled={false} + /> + ) + expect(screen.getByText('Collapse All').closest('button')).toBeDisabled() + expect(screen.getByText('Expand All').closest('button')).not.toBeDisabled() + }) +}) diff --git a/frontend/src/components/IconBtn.test.jsx b/frontend/src/components/IconBtn.test.jsx new file mode 100644 index 0000000..41c15e9 --- /dev/null +++ b/frontend/src/components/IconBtn.test.jsx @@ -0,0 +1,30 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import IconBtn from './IconBtn' + +describe('IconBtn', () => { + it('renders its children and title and fires onClick', async () => { + const onClick = vi.fn() + render( + + + + + ) + const btn = screen.getByTitle('Zoom in') + expect(btn).toHaveTextContent('+') + await userEvent.click(btn) + expect(onClick).toHaveBeenCalled() + }) + + it('applies the active styling and extra style overrides', () => { + render( + + b + + ) + const btn = screen.getByTitle('B') + expect(btn.style.color).toBe('var(--gold)') + expect(btn.style.width).toBe('50px') + }) +}) diff --git a/frontend/src/components/LinkableTag.jsx b/frontend/src/components/LinkableTag.jsx new file mode 100644 index 0000000..cb1e718 --- /dev/null +++ b/frontend/src/components/LinkableTag.jsx @@ -0,0 +1,24 @@ +import { useNavigate } from 'react-router-dom' +import { tagStyle, displayLabel } from './tagStyle' + +/** + * The clickable tag chip: navigates to the tags page filtered to this tag by its + * internal key (lowercased label). Kept in its own file so `useNavigate` is only + * ever invoked for a linkable tag — plain chips never need a Router context. + */ +export default function LinkableTag({ label, color }) { + const navigate = useNavigate() + const internal = String(label).trim().toLowerCase() + return ( + + ) +} diff --git a/frontend/src/components/MobileSidebar.jsx b/frontend/src/components/MobileSidebar.jsx index fd0cc11..8aa9ec3 100644 --- a/frontend/src/components/MobileSidebar.jsx +++ b/frontend/src/components/MobileSidebar.jsx @@ -10,6 +10,7 @@ import { LuLogOut, LuUser, LuHeart, + LuTags, LuEllipsis, LuX, LuScroll, @@ -24,6 +25,7 @@ export default function MobileSidebar({ user, onLogout, uiSettings = {} }) { const { hide_maps, hide_tokens, hide_audio, hide_campaigns } = uiSettings const moreRoutes = [ '/settings', + '/tags', ...(!hide_maps ? ['/maps'] : []), ...(!hide_tokens ? ['/tokens'] : []), ...(!hide_audio ? ['/audio'] : []), @@ -105,6 +107,12 @@ export default function MobileSidebar({ user, onLogout, uiSettings = {} }) { onClick={() => setMoreOpen(false)} /> )} + setMoreOpen(false)} + /> { expect(screen.getByText(/settings/i)).toBeInTheDocument() }) + it('lists the Tags link in the drawer pointing at /tags (issue #235)', async () => { + renderBar() + await userEvent.click(screen.getByRole('button', { name: /more/i })) + const tagsLink = screen.getByRole('link', { name: /tags/i }) + expect(tagsLink).toHaveAttribute('href', '/tags') + }) + it('closes the drawer when a drawer item is clicked', async () => { renderBar() await userEvent.click(screen.getByRole('button', { name: /more/i })) diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index fbd9607..8290ee9 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -10,14 +10,15 @@ import { LuLogOut, LuUser, LuHeart, + LuTags, LuScroll, LuX, LuPanelLeftClose, LuPanelLeftOpen, } from 'react-icons/lu' +import api from '../api' import AboutModal from './AboutModal' -const GITHUB_REPO = 'hunter-read/grimoire' const UPDATE_DISMISSED_KEY = 'grimoire_update_dismissed' function useLatestRelease() { @@ -25,14 +26,14 @@ function useLatestRelease() { useEffect(() => { let cancelled = false - fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, { - headers: { Accept: 'application/vnd.github+json' }, - signal: AbortSignal.timeout(5000), - }) - .then((r) => (r.ok ? r.json() : null)) + // Proxied through our backend (same-origin) so privacy browsers and + // request blockers don't kill the update check. Returns null when version + // checking is disabled or GitHub is unreachable. + api + .get('/latest-release') .then((data) => { - if (!cancelled && data?.tag_name) { - setLatest(data.tag_name.replace(/^v/, '')) + if (!cancelled && data?.latest_version) { + setLatest(data.latest_version) } }) .catch(() => { @@ -188,17 +189,26 @@ export default function Sidebar({ aria-label="Main navigation" style={{ padding: collapsed ? '12px 8px' : '12px 8px', flex: 1 }} > + {/* Group 1: library content (Library, Maps, Tokens, Audio) */} {!isGuest && navItem('/library', , t('nav.library'), { end: false })} {!isGuest && !hide_maps && navItem('/maps', , t('nav.maps'))} {!isGuest && !hide_tokens && navItem('/tokens', , t('nav.tokens'))} {!isGuest && !hide_audio && navItem('/audio', , t('nav.audio'))} - {!isGuest && navItem('/search', , t('nav.search'))} {!isGuest && (
)} + {/* Group 2: discovery (Search, Tags, Favorites) */} + {!isGuest && navItem('/search', , t('nav.search'))} + {!isGuest && navItem('/tags', , t('nav.tags'))} {!isGuest && navItem('/favorites', , t('nav.favorites'))} + + {!isGuest && !hide_campaigns && ( +
+ )} + + {/* Group 3: campaigns (more items to be grouped here later) */} {!hide_campaigns && navItem('/campaigns', , t('nav.campaigns'))} diff --git a/frontend/src/components/Sidebar.test.jsx b/frontend/src/components/Sidebar.test.jsx index f0c4614..3d2c38d 100644 --- a/frontend/src/components/Sidebar.test.jsx +++ b/frontend/src/components/Sidebar.test.jsx @@ -16,6 +16,13 @@ vi.mock('./AboutModal', () => ({ ), })) +// The update check goes through our backend proxy (api.get('/latest-release')). +vi.mock('../api', () => ({ + default: { get: vi.fn() }, +})) + +import api from '../api' + const DISMISSED_KEY = 'grimoire_update_dismissed' const baseStats = { @@ -35,10 +42,16 @@ const baseAbout = { const baseUser = { username: 'jdoe', display_name: 'Jane Doe', role: 'player' } -function mockFetch(tagName = null, ok = true) { - global.fetch = vi.fn().mockResolvedValue({ - ok, - json: () => Promise.resolve(tagName ? { tag_name: tagName } : {}), +// Drive the update check: resolve api.get('/latest-release') with the given +// version (tag_name-style "v2.0.0" accepted for parity with the old fixtures), +// or reject to simulate an unreachable backend. +function mockLatest(tagName = null, { reject = false } = {}) { + if (reject) { + api.get.mockRejectedValue(new Error('network error')) + return + } + api.get.mockResolvedValue({ + latest_version: tagName ? tagName.replace(/^v/, '') : null, }) } @@ -59,8 +72,8 @@ function renderSidebar(props = {}) { beforeEach(() => { vi.resetAllMocks() - // Default: fetch returns a non-ok response so no update banner appears - mockFetch(null, false) + // Default: no newer release, so no update banner appears + mockLatest(null) }) afterEach(() => { @@ -118,6 +131,19 @@ describe('Sidebar navigation visibility', () => { renderSidebar({ uiSettings: { hide_maps: true } }) expect(screen.queryByText('Maps')).toBeNull() }) + + it('shows the Tags link pointing at /tags (issue #235)', () => { + renderSidebar() + const tagsLink = screen.getByRole('link', { name: 'Tags' }) + expect(tagsLink).toHaveAttribute('href', '/tags') + }) + + it('does not show the Tags link for guests', () => { + renderSidebar({ user: { ...baseUser, role: 'guest' } }) + expect(screen.queryByRole('link', { name: 'Tags' })).toBeNull() + // Guests still see campaigns. + expect(screen.getByText('Campaigns')).toBeTruthy() + }) }) // --------------------------------------------------------------------------- @@ -192,7 +218,7 @@ describe('Sidebar stats footer', () => { describe('Sidebar update banner', () => { it('shows update banner when a newer version is available', async () => { - mockFetch('v2.0.0') + mockLatest('v2.0.0') renderSidebar() await waitFor(() => { expect(screen.getByText(/update available/i)).toBeInTheDocument() @@ -201,25 +227,25 @@ describe('Sidebar update banner', () => { }) it('does not show update banner when already on the latest version', async () => { - mockFetch('v1.2.0') // same as baseStats.version + mockLatest('v1.2.0') // same as baseStats.version renderSidebar() await waitFor(() => { - expect(global.fetch).toHaveBeenCalled() + expect(api.get).toHaveBeenCalledWith('/latest-release') }) expect(screen.queryByText(/update available/i)).toBeNull() }) it('does not show update banner when current version is newer than remote', async () => { - mockFetch('v1.1.0') + mockLatest('v1.1.0') renderSidebar() await waitFor(() => { - expect(global.fetch).toHaveBeenCalled() + expect(api.get).toHaveBeenCalledWith('/latest-release') }) expect(screen.queryByText(/update available/i)).toBeNull() }) it('does not show update banner when fetch fails', async () => { - global.fetch = vi.fn().mockRejectedValue(new Error('network error')) + mockLatest(null, { reject: true }) renderSidebar() // Give time for fetch to settle await act(async () => { @@ -229,10 +255,10 @@ describe('Sidebar update banner', () => { }) it('does not show update banner when stats version is "dev"', async () => { - mockFetch('v2.0.0') + mockLatest('v2.0.0') renderSidebar({ stats: { ...baseStats, version: 'dev' } }) await waitFor(() => { - expect(global.fetch).toHaveBeenCalled() + expect(api.get).toHaveBeenCalledWith('/latest-release') }) expect(screen.queryByText(/update available/i)).toBeNull() }) @@ -244,7 +270,7 @@ describe('Sidebar update banner', () => { describe('Sidebar update banner dismiss', () => { it('hides the banner when X is clicked', async () => { - mockFetch('v2.0.0') + mockLatest('v2.0.0') renderSidebar() await waitFor(() => { expect(screen.getByText(/update available/i)).toBeInTheDocument() @@ -254,7 +280,7 @@ describe('Sidebar update banner dismiss', () => { }) it('saves the dismissed version to localStorage when X is clicked', async () => { - mockFetch('v2.0.0') + mockLatest('v2.0.0') renderSidebar() await waitFor(() => { expect(screen.getByText(/update available/i)).toBeInTheDocument() @@ -265,17 +291,17 @@ describe('Sidebar update banner dismiss', () => { it('does not show banner on mount when that version was already dismissed', async () => { localStorage.setItem(DISMISSED_KEY, '2.0.0') - mockFetch('v2.0.0') + mockLatest('v2.0.0') renderSidebar() await waitFor(() => { - expect(global.fetch).toHaveBeenCalled() + expect(api.get).toHaveBeenCalledWith('/latest-release') }) expect(screen.queryByText(/update available/i)).toBeNull() }) it('shows banner again when a newer version than the dismissed one is available', async () => { localStorage.setItem(DISMISSED_KEY, '2.0.0') - mockFetch('v2.1.0') + mockLatest('v2.1.0') renderSidebar() await waitFor(() => { expect(screen.getByText(/update available/i)).toBeInTheDocument() diff --git a/frontend/src/components/Tag.jsx b/frontend/src/components/Tag.jsx index d792e4f..3018913 100644 --- a/frontend/src/components/Tag.jsx +++ b/frontend/src/components/Tag.jsx @@ -1,20 +1,16 @@ -export default function Tag({ label, color }) { - return ( - - {label ? label.charAt(0).toUpperCase() + label.slice(1) : label} - - ) +import LinkableTag from './LinkableTag' +import { tagStyle, displayLabel } from './tagStyle' + +/** + * A tag chip. When `linkable` (default) and a label is present, clicking it + * navigates to the tags page filtered to that tag (issue #235.7): matching is by + * the tag's internal key, i.e. the lowercased label. Pass `linkable={false}` to + * render a plain, non-interactive chip (e.g. genres, or inside another clickable + * card that already handles tag clicks). + */ +export default function Tag({ label, color, linkable = true }) { + if (linkable && label) { + return + } + return {displayLabel(label)} } diff --git a/frontend/src/components/Tag.test.jsx b/frontend/src/components/Tag.test.jsx index 14b1f95..e06cd5e 100644 --- a/frontend/src/components/Tag.test.jsx +++ b/frontend/src/components/Tag.test.jsx @@ -1,34 +1,61 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' import Tag from './Tag' +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { ...actual, useNavigate: () => mockNavigate } +}) + +const renderTag = (props) => + render( + + + + ) + describe('Tag', () => { it('renders the label text', () => { - render() + renderTag({ label: 'fantasy' }) expect(screen.getByText('Fantasy')).toBeInTheDocument() }) it('capitalizes the first letter', () => { - render() + renderTag({ label: 'dungeon-crawl' }) expect(screen.getByText('Dungeon-crawl')).toBeInTheDocument() }) it('handles single-character labels', () => { - render() + renderTag({ label: 'a' }) expect(screen.getByText('A')).toBeInTheDocument() }) it('renders without throwing when label is undefined', () => { - render() + renderTag({}) }) it('renders without throwing when label is an empty string', () => { - render() + renderTag({ label: '' }) }) it('applies a custom background color when provided', () => { - render() + renderTag({ label: 'test', color: '#ff0000' }) const el = screen.getByText('Test') expect(el.style.background).toBe('rgb(255, 0, 0)') }) + + it('navigates to the tags page (by internal key) when a linkable tag is clicked', async () => { + renderTag({ label: 'Draw Steel' }) + await userEvent.click(screen.getByText('Draw Steel')) + expect(mockNavigate).toHaveBeenCalledWith('/tags?tag=draw%20steel') + }) + + it('renders a non-interactive span when linkable is false', () => { + renderTag({ label: 'fantasy', linkable: false }) + const el = screen.getByText('Fantasy') + expect(el.tagName).toBe('SPAN') + }) }) diff --git a/frontend/src/components/TagSection.test.jsx b/frontend/src/components/TagSection.test.jsx new file mode 100644 index 0000000..32e600c --- /dev/null +++ b/frontend/src/components/TagSection.test.jsx @@ -0,0 +1,39 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import TagSection from './TagSection' + +describe('TagSection', () => { + it('renders the label and tag pills', () => { + render() + expect(screen.getByText('Tags')).toBeInTheDocument() + expect(screen.getByText('forest')).toBeInTheDocument() + expect(screen.getByText('cave')).toBeInTheDocument() + }) + + it('shows the no-tags label when empty', () => { + render() + expect(screen.getByText('No tags yet')).toBeInTheDocument() + }) + + it('renders an edit button when editable and fires onEdit', async () => { + const onEdit = vi.fn() + render( + + ) + await userEvent.click(screen.getByText('Edit')) + expect(onEdit).toHaveBeenCalled() + }) + + it('hides the edit button when not editable', () => { + render() + expect(screen.queryByText('Edit')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/ToggleSwitch.jsx b/frontend/src/components/ToggleSwitch.jsx new file mode 100644 index 0000000..f9df62b --- /dev/null +++ b/frontend/src/components/ToggleSwitch.jsx @@ -0,0 +1,74 @@ +/** + * A small on/off switch styled as a toggle. + * + * Props: + * - labelFirst: render the label before the switch (default: switch then label) + * - pill: wrap the whole control in a bordered, button-like pill so clicking + * anywhere on it toggles (the whole thing is a
) : ( @@ -282,6 +283,7 @@ export default function AudioDetailView() { tags={track.tags} onSave={saveTrackTags} onCancel={() => setEditingTrackTags(false)} + resourceType="audio" />
) : ( diff --git a/frontend/src/components/campaigns/CampaignRoleBadge.test.jsx b/frontend/src/components/campaigns/CampaignRoleBadge.test.jsx new file mode 100644 index 0000000..2eb0699 --- /dev/null +++ b/frontend/src/components/campaigns/CampaignRoleBadge.test.jsx @@ -0,0 +1,10 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import CampaignRoleBadge from './CampaignRoleBadge' + +describe('CampaignRoleBadge', () => { + it('renders the label', () => { + render() + expect(screen.getByText('Game Master')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/campaigns/ResourcePicker.jsx b/frontend/src/components/campaigns/ResourcePicker.jsx index 02c9de1..64d0aa3 100644 --- a/frontend/src/components/campaigns/ResourcePicker.jsx +++ b/frontend/src/components/campaigns/ResourcePicker.jsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { LuSearch, LuBookOpen, LuTrash2 } from 'react-icons/lu' -import { campaigns } from '../../api' +import { LuSearch, LuBookOpen, LuTrash2, LuTags } from 'react-icons/lu' +import { campaigns, tags as tagsApi } from '../../api' import Spinner from '../Spinner' import ResourceGroup from './ResourceGroup' import { TYPE_ICONS, PICKER_TYPES, resourceKey, buildFolderTree } from './resourcesShared' @@ -44,6 +44,10 @@ export default function ResourcePicker({ const [query, setQuery] = useState('') const [typeFilter, setTypeFilter] = useState('book') const [openKeys, setOpenKeys] = useState(() => new Set()) + // "Add by tag" (issue #235.8): the loaded tag list and any add error. + const [tagList, setTagList] = useState([]) + const [tagValue, setTagValue] = useState('') + const [tagAdding, setTagAdding] = useState(false) // Load books (optionally scoped to the campaign's system) plus every map, // token, and audio item, once. Empty query returns the full set per type; we @@ -67,6 +71,53 @@ export default function ResourcePicker({ } }, [systemId]) + // Load the tag list once, for the "add by tag" picker. + useEffect(() => { + let cancelled = false + tagsApi + .list() + .then((r) => !cancelled && setTagList(r.tags || [])) + .catch(() => !cancelled && setTagList([])) + return () => { + cancelled = true + } + }, []) + + // Add every campaign-addable resource carrying the chosen tag to the selection + // (issue #235.8). Systems aren't campaign resources, so they're skipped; rows + // already selected or excluded are left as-is. Metadata (name) is enriched from + // the loaded resource set when available. + const addByTag = async (internal) => { + if (!internal || tagAdding) return + setTagAdding(true) + try { + const { items } = await tagsApi.items(internal) + const byKey = new Map((all || []).map((r) => [resourceKey(r), r])) + setSelected((prev) => { + const have = new Set(prev.map(resourceKey)) + const additions = [] + for (const it of items || []) { + if (!PICKER_TYPES.includes(it.item_type)) continue // skip systems + const key = `${it.item_type}:${it.item_id}` + if (have.has(key)) continue + if (excludeKeys && excludeKeys.has(key)) continue + const meta = byKey.get(key) + additions.push({ + resource_type: it.item_type, + resource_id: it.item_id, + name: meta?.name || it.title || it.filename || it.item_id, + visibility: 'public', + }) + have.add(key) + } + return additions.length ? [...prev, ...additions] : prev + }) + setTagValue('') + } finally { + setTagAdding(false) + } + } + // Pre-select core books for the campaign's system the first time resources // load (wizard only). Skipped once the user has picked anything. useEffect(() => { @@ -181,6 +232,34 @@ export default function ResourcePicker({ />
+ {/* Add by tag (issue #235.8) */} + {tagList.length > 0 && ( +
+ + + +
+ )} + {/* Folder browser */}
{all === null ? ( diff --git a/frontend/src/components/campaigns/ResourcePicker.test.jsx b/frontend/src/components/campaigns/ResourcePicker.test.jsx index 9966102..c41a540 100644 --- a/frontend/src/components/campaigns/ResourcePicker.test.jsx +++ b/frontend/src/components/campaigns/ResourcePicker.test.jsx @@ -3,10 +3,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import ResourcePicker from './ResourcePicker' -import { campaigns } from '../../api' +import { campaigns, tags } from '../../api' vi.mock('../../api', () => ({ campaigns: { searchResources: vi.fn() }, + tags: { + list: vi.fn(() => Promise.resolve({ tags: [] })), + items: vi.fn(() => Promise.resolve({ items: [] })), + }, })) // searchResources is called once per type (book, map, token, audio). Resolve @@ -124,4 +128,65 @@ describe('ResourcePicker', () => { expect(screen.queryByText('core')).not.toBeInTheDocument() expect(screen.queryByText("Player's Handbook")).not.toBeInTheDocument() }) + + // --- Add by tag (issue #235.8) --- + + describe('add by tag', () => { + beforeEach(() => { + tags.list.mockResolvedValue({ + tags: [{ internal: 'strahd', display: 'Strahd', count: 2 }], + }) + }) + + it('shows the tag picker only when tags exist', async () => { + tags.list.mockResolvedValueOnce({ tags: [] }) + render() + await screen.findByRole('button', { name: 'Books' }) + expect(screen.queryByLabelText(/add all with tag/i)).not.toBeInTheDocument() + }) + + it('adds every campaign-addable resource carrying the chosen tag', async () => { + tags.items.mockResolvedValue({ + internal: 'strahd', + display: 'Strahd', + items: [ + { item_type: 'book', item_id: 'b2' }, + { item_type: 'map', item_id: 'm1' }, + // A system is not a campaign resource and must be skipped. + { item_type: 'system', item_id: 's9' }, + ], + }) + render() + const select = await screen.findByLabelText(/add all with tag/i) + await userEvent.selectOptions(select, 'strahd') + await userEvent.click(screen.getByRole('button', { name: 'Add' })) + // Two addable resources land in the selection (the system is skipped). + expect(await screen.findByText('Selected (2)')).toBeInTheDocument() + expect(tags.items).toHaveBeenCalledWith('strahd') + // The book's real name is enriched from the loaded resource set. + expect(screen.getByText('Curse of Strahd')).toBeInTheDocument() + }) + + it('does not duplicate a resource already selected', async () => { + tags.items.mockResolvedValue({ + internal: 'strahd', + display: 'Strahd', + items: [{ item_type: 'map', item_id: 'm1' }], + }) + render( + + ) + expect(await screen.findByText('Selected (1)')).toBeInTheDocument() + const select = await screen.findByLabelText(/add all with tag/i) + await userEvent.selectOptions(select, 'strahd') + await userEvent.click(screen.getByRole('button', { name: 'Add' })) + // Still exactly one selected map (no duplicate). + await waitFor(() => expect(tags.items).toHaveBeenCalled()) + expect(screen.getByText('Selected (1)')).toBeInTheDocument() + }) + }) }) diff --git a/frontend/src/components/campaigns/WikiMarkdown.jsx b/frontend/src/components/campaigns/WikiMarkdown.jsx index 28d2a83..e0be073 100644 --- a/frontend/src/components/campaigns/WikiMarkdown.jsx +++ b/frontend/src/components/campaigns/WikiMarkdown.jsx @@ -23,16 +23,31 @@ const EMBED_PREFIXES = ['book:', 'map:', 'token:', 'audio:', 'file:', 'image:'] // won't. The match spans newlines so a secret can wrap several lines/paragraphs. const SECRET_RE = /\|\|([\s\S]*?)\|\|/g +// Must match the backend slugify (backend/routers/campaigns/wiki.py) exactly, or +// [[links]] won't resolve to their pages. Python's \w is Unicode-aware, so we use +// Unicode property escapes (with the `u` flag) instead of JS's ASCII-only \w — +// otherwise non-ASCII letters like ä/ö/ü/ß are stripped and the slug diverges +// (issue #252). `_` is kept here (as \w does) then collapsed to `-` below. function slugify(title) { return ( title .toLowerCase() - .replace(/[^\w\s-]/g, '') + .replace(/[^\p{L}\p{N}_\s-]/gu, '') .trim() .replace(/[\s_-]+/g, '-') || 'untitled' ) } +// Decode a percent-encoded slug, tolerating malformed input (a stray "%" would +// otherwise throw). Returns the original string if it can't be decoded. +function safeDecode(s) { + try { + return decodeURIComponent(s) + } catch { + return s + } +} + function escapeLinkText(text) { // Keep link text from breaking the markdown link syntax. Escape backslashes // first so a user-supplied trailing "\" can't combine with the "]" we add and @@ -122,7 +137,10 @@ export default function WikiMarkdown({ body, campaignId, pageSlugs = [], onOpenS ) } if (href?.startsWith('grimoire-wiki:')) { - const slug = href.slice('grimoire-wiki:'.length) + // react-markdown percent-encodes non-ASCII in hrefs, so a Unicode slug + // (e.g. "breitfuß") arrives encoded ("breitfu%C3%9F"). Decode it back so + // it matches the backend slug (issue #252). + const slug = safeDecode(href.slice('grimoire-wiki:'.length)) const exists = slugSet.has(slug) return ( +
+ +
+ {/* Favorites (and any other boolean toggles) are always the first + filter option. */} + {booleanToggles.map(renderToggle)} + + {/* Title search box (all categories). */} + {showSearch && ( +
+ +
+ + setFilter('search', e.target.value || undefined)} + placeholder={searchPlaceholder || t('sortFilter.searchPlaceholder')} + style={{ ...control, paddingLeft: 28 }} + /> +
+
+ )} + + {selectFilters.map((f) => ( +
+ + +
+ ))} + + {multiFilters.map((f) => ( +
+ + setFilter(f.key, next.length ? next : undefined)} + emptyLabel={f.emptyLabel} + searchPlaceholder={f.searchPlaceholder} + /> +
+ ))} + + {otherToggles.map(renderToggle)} +
+ + {/* Save-as-filter section. */} +
+ +
+ setSaveName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && submitSave()} + placeholder={t('sortFilter.savePrompt')} + aria-label={t('sortFilter.savePrompt')} + style={{ ...control, flex: '1 1 160px' }} + /> + + +
+
+ +
+ + +
+ + + ) +} + +const overlay = { + position: 'fixed', + inset: 0, + background: 'rgba(0,0,0,0.6)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 100, + padding: 16, +} +const panel = { + background: 'var(--bg-panel)', + border: '1px solid var(--border)', + borderRadius: 12, + padding: 20, + width: '100%', + maxWidth: 460, + maxHeight: '85vh', + overflowY: 'auto', +} +const header = { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16, +} +const closeBtn = { + background: 'none', + border: 'none', + cursor: 'pointer', + color: 'var(--text-muted)', + display: 'flex', +} +const label = { + display: 'block', + fontSize: 12, + color: 'var(--text-muted)', + fontWeight: 500, + marginBottom: 6, +} +const control = { + fontSize: 13, + padding: '6px 10px', + borderRadius: 6, + background: 'var(--bg-input)', + border: '1px solid var(--border)', + color: 'var(--text)', + width: '100%', + boxSizing: 'border-box', +} diff --git a/frontend/src/components/library/FilterModal.test.jsx b/frontend/src/components/library/FilterModal.test.jsx new file mode 100644 index 0000000..e3bdae3 --- /dev/null +++ b/frontend/src/components/library/FilterModal.test.jsx @@ -0,0 +1,107 @@ +import { describe, it, expect, vi } from 'vitest' +import { useState } from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import FilterModal from './FilterModal' + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k) => k }) })) + +const selectFilters = [ + { + key: 'genre', + label: 'Genre', + allLabel: 'All', + options: [{ value: 'Fantasy', label: 'Fantasy' }], + }, +] +const multiFilters = [ + { key: 'tags', label: 'Tags', emptyLabel: 'No tags', options: [{ value: 'osr', label: 'osr' }] }, +] +const toggleFilters = [ + { key: 'favorites', label: 'Favorites', boolean: true }, + { key: 'explicit', label: 'Explicit' }, +] + +function Harness({ initial = { sort: 'name', order: 'asc', filters: {} }, onSavePreset, onClose }) { + const [state, setState] = useState(initial) + return ( +
+ + {JSON.stringify(state)} +
+ ) +} + +describe('FilterModal', () => { + it('renders the Favorites toggle as the first filter (before the search box)', () => { + render() + const fav = screen.getByRole('checkbox', { name: /Favorites/ }) + const search = screen.getByLabelText('sortFilter.searchLabel') + // Favorites appears before the search input in DOM order. + expect(fav.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }) + + it('applies a title search filter', async () => { + render() + await userEvent.type(screen.getByLabelText('sortFilter.searchLabel'), 'goblin') + expect(screen.getByTestId('state').textContent).toContain('"search":"goblin"') + }) + + it('applies a select filter', async () => { + render() + await userEvent.selectOptions(screen.getByLabelText('Genre'), 'Fantasy') + expect(screen.getByTestId('state').textContent).toContain('"genre":"Fantasy"') + }) + + it('toggles a value in the multiselect dropdown', async () => { + render() + // Open the "Tags" dropdown, then check an option. + await userEvent.click(screen.getByRole('button', { name: 'Tags' })) + await userEvent.click(screen.getByRole('checkbox', { name: 'osr' })) + expect(screen.getByTestId('state').textContent).toContain('"tags":["osr"]') + }) + + it('toggles a boolean checkbox', async () => { + render() + await userEvent.click(screen.getByRole('checkbox', { name: /Favorites/ })) + expect(screen.getByTestId('state').textContent).toContain('"favorites":true') + }) + + it('sets a tri-state select filter', async () => { + render() + await userEvent.selectOptions(screen.getByLabelText('Explicit'), 'true') + expect(screen.getByTestId('state').textContent).toContain('"explicit":true') + }) + + it('clears all filters', async () => { + render() + await userEvent.click(screen.getByText('sortFilter.clear')) + expect(screen.getByTestId('state').textContent).toContain('"filters":{}') + }) + + it('saves a preset (with default) and closes', async () => { + const onSavePreset = vi.fn() + const onClose = vi.fn() + render() + await userEvent.type(screen.getByLabelText('sortFilter.savePrompt'), 'My Preset') + await userEvent.click(screen.getByText('sortFilter.saveAsDefault')) + await userEvent.click(screen.getByText('common.save')) + expect(onSavePreset).toHaveBeenCalledWith('My Preset', { asDefault: true }) + expect(onClose).toHaveBeenCalled() + }) + + it('closes on the Done button', async () => { + const onClose = vi.fn() + render() + await userEvent.click(screen.getByText('common.done')) + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/library/SearchInput.jsx b/frontend/src/components/library/SearchInput.jsx new file mode 100644 index 0000000..43d4677 --- /dev/null +++ b/frontend/src/components/library/SearchInput.jsx @@ -0,0 +1,61 @@ +import { LuSearch, LuX } from 'react-icons/lu' + +/** + * Compact, standalone search box (magnifier icon + clear button) matching the + * system-detail search bar. Used to keep title/name search visible outside the + * filter modal for the media galleries. + */ +export default function SearchInput({ value, onChange, placeholder, ariaLabel, style }) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + style={{ + width: '100%', + fontSize: 13, + padding: '6px 28px 6px 30px', + borderRadius: 6, + border: '1px solid var(--border)', + background: 'var(--bg-card)', + boxSizing: 'border-box', + }} + /> + {value && ( + + )} +
+ ) +} diff --git a/frontend/src/components/library/SearchInput.test.jsx b/frontend/src/components/library/SearchInput.test.jsx new file mode 100644 index 0000000..f6e9c43 --- /dev/null +++ b/frontend/src/components/library/SearchInput.test.jsx @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import SearchInput from './SearchInput' + +describe('SearchInput', () => { + it('renders with a placeholder and forwards typed input', async () => { + const onChange = vi.fn() + render() + const input = screen.getByPlaceholderText('Filter maps…') + await userEvent.type(input, 'd') + expect(onChange).toHaveBeenCalledWith('d') + }) + + it('uses the placeholder as the aria-label by default', () => { + render() + expect(screen.getByLabelText('Filter tokens…')).toBeInTheDocument() + }) + + it('prefers an explicit ariaLabel over the placeholder', () => { + render( + + ) + expect(screen.getByLabelText('Search audio')).toBeInTheDocument() + }) + + it('hides the clear button when empty', () => { + render() + expect(screen.queryByRole('button', { name: /clear search/i })).not.toBeInTheDocument() + }) + + it('shows a clear button when there is a value and clears on click', async () => { + const onChange = vi.fn() + render() + await userEvent.click(screen.getByRole('button', { name: /clear search/i })) + expect(onChange).toHaveBeenCalledWith('') + }) +}) diff --git a/frontend/src/components/library/SortFilterBar.jsx b/frontend/src/components/library/SortFilterBar.jsx new file mode 100644 index 0000000..986d8f3 --- /dev/null +++ b/frontend/src/components/library/SortFilterBar.jsx @@ -0,0 +1,295 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + LuArrowUp, + LuArrowDown, + LuArrowDownUp, + LuBookmark, + LuStar, + LuX, + LuFilter, +} from 'react-icons/lu' +import FilterModal from './FilterModal' + +/** + * Compact, stash-inspired sort + filter toolbar with server-backed saved + * presets. Sort lives inline (label + connected select/order button); all + * filters live in a modal opened by the "Filters (N)" button. + * + * Saved-filter state is owned by the parent (so a per-scope default can be + * applied on page load) and passed in via props: + * - saved: [{ id, name, state, is_default }] + * - onSavePreset: (name, { asDefault }) => void — save current state + * - onDeletePreset: (id) => void + * - onSetDefault: (id, value) => void + * + * Other props: + * - state: { sort, order, filters: {...} } + * - onChange: (nextState) => void + * - sortOptions: [{ value, label }] + * - selectFilters / multiFilters / toggleFilters: filter definitions (rendered in the modal) + */ +export default function SortFilterBar({ + state, + onChange, + sortOptions, + selectFilters = [], + multiFilters = [], + toggleFilters = [], + showSearch = true, + searchPlaceholder, + saved = [], + onSavePreset, + onDeletePreset, + onSetDefault, +}) { + const { t } = useTranslation() + const [showSaved, setShowSaved] = useState(false) + const [showFilters, setShowFilters] = useState(false) + + const filters = state.filters || {} + const setSort = (sort) => onChange({ ...state, sort }) + const toggleOrder = () => onChange({ ...state, order: state.order === 'asc' ? 'desc' : 'asc' }) + + const isActive = (v) => + v !== undefined && + v !== null && + v !== '' && + v !== 'any' && + !(Array.isArray(v) && v.length === 0) + const activeFilterCount = Object.values(filters).filter(isActive).length + + const controlStyle = { + fontSize: 13, + padding: '5px 8px', + borderRadius: 6, + background: 'var(--bg-input)', + border: '1px solid var(--border)', + color: 'var(--text)', + } + + const groupLabelStyle = { + fontSize: 11, + textTransform: 'uppercase', + letterSpacing: '0.06em', + color: 'var(--text-muted)', + fontWeight: 600, + display: 'inline-flex', + alignItems: 'center', + gap: 4, + } + + return ( +
+ {/* Sort group: label + icon + connected select/order button. */} +
+ + + {t('sortFilter.sort')} + +
+ + +
+
+ + {/* Filters button → modal. Shows the active-filter count. `marginLeft: auto` + pushes it (and the saved-filters menu) to the right, leaving Sort left. */} + + + {/* Saved filters menu. */} +
+ + {showSaved && ( +
+ {saved.length === 0 ? ( +
+ {t('sortFilter.noSaved')} +
+ ) : ( + saved.map((f) => ( +
+ + + +
+ )) + )} +
+ )} +
+ + {showFilters && ( + setShowFilters(false)} + /> + )} +
+ ) +} diff --git a/frontend/src/components/library/SortFilterBar.test.jsx b/frontend/src/components/library/SortFilterBar.test.jsx new file mode 100644 index 0000000..0f20b85 --- /dev/null +++ b/frontend/src/components/library/SortFilterBar.test.jsx @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useState } from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import SortFilterBar from './SortFilterBar' + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k) => k }) })) + +const sortOptions = [ + { value: 'name', label: 'Name' }, + { value: 'page_count', label: 'Pages' }, +] +const selectFilters = [ + { + key: 'genre', + label: 'Genre', + allLabel: 'All', + options: [{ value: 'Fantasy', label: 'Fantasy' }], + }, +] +const toggleFilters = [{ key: 'explicit', label: 'Explicit' }] +const multiFilters = [ + { key: 'tags', label: 'Tags', emptyLabel: 'No tags', options: [{ value: 'osr', label: 'osr' }] }, +] +const booleanToggles = [{ key: 'favorites', label: 'Favorites', boolean: true }] + +function Harness({ + initial = { sort: 'name', order: 'asc', filters: {} }, + withMulti = false, + withBoolean = false, + saved = [], + onSavePreset, + onSetDefault, + onDeletePreset, +}) { + const [state, setState] = useState(initial) + return ( +
+ + {JSON.stringify(state)} +
+ ) +} + +// Open the filter modal (all filter controls live there now). +const openFilters = async () => + userEvent.click(screen.getByRole('button', { name: 'sortFilter.filters' })) + +beforeEach(() => localStorage.clear()) + +describe('SortFilterBar', () => { + it('changes the sort key', async () => { + render() + await userEvent.selectOptions(screen.getByLabelText('sortFilter.sort'), 'page_count') + expect(screen.getByTestId('state').textContent).toContain('"sort":"page_count"') + }) + + it('toggles sort order', async () => { + render() + await userEvent.click(screen.getByLabelText('sortFilter.ascending')) + expect(screen.getByTestId('state').textContent).toContain('"order":"desc"') + }) + + it('applies a select filter from the modal', async () => { + render() + await openFilters() + await userEvent.selectOptions(screen.getByLabelText('Genre'), 'Fantasy') + expect(screen.getByTestId('state').textContent).toContain('"genre":"Fantasy"') + }) + + it('applies a tri-state toggle filter from the modal', async () => { + render() + await openFilters() + await userEvent.selectOptions(screen.getByLabelText('Explicit'), 'true') + expect(screen.getByTestId('state').textContent).toContain('"explicit":true') + }) + + it('the tri-state default (any) does not add a filter', async () => { + render() + await openFilters() + await userEvent.selectOptions(screen.getByLabelText('Explicit'), 'any') + expect(screen.getByTestId('state').textContent).not.toContain('"explicit"') + }) + + it('toggles a boolean checkbox filter on and off', async () => { + render() + await openFilters() + const fav = screen.getByRole('checkbox', { name: /Favorites/ }) + await userEvent.click(fav) + expect(screen.getByTestId('state').textContent).toContain('"favorites":true') + await userEvent.click(fav) + expect(screen.getByTestId('state').textContent).not.toContain('"favorites"') + }) + + it('selects values in a multiselect filter', async () => { + render() + await openFilters() + await userEvent.click(screen.getByRole('button', { name: 'Tags' })) + await userEvent.click(screen.getByRole('checkbox', { name: 'osr' })) + expect(screen.getByTestId('state').textContent).toContain('"tags":["osr"]') + await userEvent.click(screen.getByRole('checkbox', { name: 'osr' })) + expect(screen.getByTestId('state').textContent).not.toContain('"tags"') + }) + + it('shows the Filters button with a count', async () => { + render() + const btn = screen.getByRole('button', { name: 'sortFilter.filters' }) + expect(btn.textContent).toContain('1') + }) + + it('clears active filters from the modal', async () => { + render() + await openFilters() + await userEvent.click(screen.getByText('sortFilter.clear')) + expect(screen.getByTestId('state').textContent).toContain('"filters":{}') + }) + + it('saves a preset from the modal (with default)', async () => { + const onSavePreset = vi.fn() + render() + await openFilters() + await userEvent.type(screen.getByLabelText('sortFilter.savePrompt'), 'Preset A') + await userEvent.click(screen.getByText('sortFilter.saveAsDefault')) + await userEvent.click(screen.getByText('common.save')) + expect(onSavePreset).toHaveBeenCalledWith('Preset A', { asDefault: true }) + }) + + it('applies a saved preset from the menu', async () => { + const saved = [ + { id: 'p1', name: 'By pages', state: { sort: 'page_count', order: 'desc', filters: {} } }, + ] + render() + await userEvent.click(screen.getByRole('button', { name: 'sortFilter.savedFilters' })) + await userEvent.click(screen.getByText('By pages')) + expect(screen.getByTestId('state').textContent).toContain('"sort":"page_count"') + }) + + it('sets and deletes a preset via its row actions', async () => { + const onSetDefault = vi.fn() + const onDeletePreset = vi.fn() + const saved = [{ id: 'p1', name: 'Keep', state: {}, is_default: false }] + render() + await userEvent.click(screen.getByRole('button', { name: 'sortFilter.savedFilters' })) + await userEvent.click(screen.getByLabelText('sortFilter.setDefault Keep')) + expect(onSetDefault).toHaveBeenCalledWith('p1', true) + await userEvent.click(screen.getByLabelText('sortFilter.delete Keep')) + expect(onDeletePreset).toHaveBeenCalledWith('p1') + }) +}) diff --git a/frontend/src/components/library/SystemCard.jsx b/frontend/src/components/library/SystemCard.jsx index 8c43f78..f6bf5d5 100644 --- a/frontend/src/components/library/SystemCard.jsx +++ b/frontend/src/components/library/SystemCard.jsx @@ -12,8 +12,8 @@ import LazyImg from '../LazyImg' * * In bulk-select mode (`selectable`), clicking the card toggles its selection * (with shift / cmd-click modifiers) instead of navigating, and a checkbox is - * shown. Tags on the full card are clickable to toggle a tag filter via - * `onTagClick`; `activeTags` highlights the ones currently filtering. + * shown. Tag chips on the full card link to the tags page (grid tag filtering + * is handled by the Filters modal's Tags dropdown). */ export default function SystemCard({ system, @@ -23,8 +23,6 @@ export default function SystemCard({ selectable = false, selected = false, onToggleSelect, - onTagClick, - activeTags, }) { const { t } = useTranslation() const [hovered, setHovered] = useState(false) @@ -126,9 +124,16 @@ export default function SystemCard({ gap: 8, marginTop: 4, alignItems: 'center', + flexWrap: 'wrap', }} > {t('library.bookCount', { count: system.book_count })} + {system.system_family && · {system.system_family}} + {(system.genres || []).length > 0 && ( + + · {(system.genres || []).slice(0, 3).join(', ')} + + )} {system.is_explicit && 18+} @@ -324,27 +329,16 @@ export default function SystemCard({
- {(system.tags || []).slice(0, 4).map((tag) => - onTagClick && !selectable ? ( - - ) : ( - - ) - )} + {/* Genres are shown before tags (issue #202), in the genre colour. + Genres aren't tags, so they never navigate. */} + {(system.genres || []).slice(0, 3).map((g) => ( + + ))} + {/* Tag chips navigate to the tags page (issue #235.7); grid tag + filtering lives in the Filters modal's Tags dropdown. */} + {(system.tags || []).slice(0, 4).map((tag) => ( + + ))}
diff --git a/frontend/src/components/library/SystemCard.test.jsx b/frontend/src/components/library/SystemCard.test.jsx index 7422867..85de584 100644 --- a/frontend/src/components/library/SystemCard.test.jsx +++ b/frontend/src/components/library/SystemCard.test.jsx @@ -1,6 +1,7 @@ -import { describe, it, expect, vi } from 'vitest' -import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render as rtlRender, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' import SystemCard from './SystemCard' vi.mock('../../context/FavoritesContext', () => ({ @@ -11,6 +12,16 @@ vi.mock('../../api', () => ({ mediaUrl: (path) => `http://localhost${path}`, })) +// Tag chips navigate via react-router's useNavigate — capture it. +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useNavigate: () => mockNavigate } +}) + +// SystemCard renders LinkableTag (uses useNavigate), so every render needs a Router. +const render = (ui) => rtlRender({ui}) + function makeSystem(overrides = {}) { return { id: 'sys-1', @@ -26,6 +37,8 @@ function makeSystem(overrides = {}) { } describe('SystemCard', () => { + beforeEach(() => mockNavigate.mockClear()) + describe('full card layout', () => { it('renders name, description, publishers, and tags', () => { render( @@ -64,33 +77,13 @@ describe('SystemCard', () => { expect(screen.getByText('18+')).toBeInTheDocument() }) - it('renders clickable tag buttons that toggle a filter', async () => { - const onTagClick = vi.fn() - render( - - ) - await userEvent.click(screen.getByRole('button', { name: /filter by osr/i })) - expect(onTagClick).toHaveBeenCalledWith('osr') - }) - - it('marks an active tag as pressed', () => { - render( - - ) - expect(screen.getByRole('button', { name: /filter by osr/i })).toHaveAttribute( - 'aria-pressed', - 'true' - ) + it('navigates to the tags page (by internal key) when a tag chip is clicked', async () => { + const onClick = vi.fn() + render() + await userEvent.click(screen.getByRole('button', { name: 'Osr' })) + expect(mockNavigate).toHaveBeenCalledWith('/tags?tag=osr') + // Clicking a chip must not also trigger the card's own navigation. + expect(onClick).not.toHaveBeenCalled() }) }) @@ -133,17 +126,20 @@ describe('SystemCard', () => { expect(screen.queryByRole('button', { name: /favorite/i })).not.toBeInTheDocument() }) - it('tags are not clickable filters while selecting', () => { + it('tag chips still navigate to the tags page while selecting', async () => { + const onToggleSelect = vi.fn() render( ) - expect(screen.queryByRole('button', { name: /filter by osr/i })).not.toBeInTheDocument() - expect(screen.getByText('Osr')).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Osr' })) + expect(mockNavigate).toHaveBeenCalledWith('/tags?tag=osr') + // Clicking the chip does not toggle the card's selection. + expect(onToggleSelect).not.toHaveBeenCalled() }) }) diff --git a/frontend/src/components/library/applyBookSortFilter.js b/frontend/src/components/library/applyBookSortFilter.js new file mode 100644 index 0000000..58a2f01 --- /dev/null +++ b/frontend/src/components/library/applyBookSortFilter.js @@ -0,0 +1,43 @@ +// Client-side filter predicate + sort comparator for a system's books, +// mirroring applySystemSortFilter but for the SystemDetailView (which keeps its +// category grouping — so this exposes a predicate + a comparator rather than a +// flat filtered/sorted list). + +const hasAll = (field, wanted) => { + const vals = (field || []).map((v) => String(v).toLowerCase()) + return wanted.every((w) => vals.includes(w)) +} + +/** + * Build a `book => boolean` predicate from the filter state. + * @param filters { favorites, explicit, genres:[], tags:[] } + * @param opts { isFavorite: (id) => bool } + */ +export function bookFilterPredicate(filters = {}, opts = {}) { + const { isFavorite } = opts + const wantGenres = Array.isArray(filters.genres) ? filters.genres.map((g) => g.toLowerCase()) : [] + const wantTags = Array.isArray(filters.tags) ? filters.tags.map((tg) => tg.toLowerCase()) : [] + const search = (filters.search || '').trim().toLowerCase() + return (book) => { + if (search && !(book.title || '').toLowerCase().includes(search)) return false + if (filters.favorites === true && isFavorite && !isFavorite(book.id)) return false + if (filters.explicit !== undefined && Boolean(book.is_explicit) !== filters.explicit) + return false + if (wantGenres.length && !hasAll(book.genres, wantGenres)) return false + if (wantTags.length && !hasAll(book.tags, wantTags)) return false + return true + } +} + +/** Comparator for a book list given a sort key + order. */ +export function bookComparator(sort = 'title', order = 'asc') { + const dir = order === 'desc' ? -1 : 1 + const cmp = { + title: (a, b) => a.title.localeCompare(b.title), + year: (a, b) => (a.year == null ? Infinity : a.year) - (b.year == null ? Infinity : b.year), + page_count: (a, b) => (a.page_count || 0) - (b.page_count || 0), + size: (a, b) => (a.file_size || 0) - (b.file_size || 0), + } + const fn = cmp[sort] || cmp.title + return (a, b) => dir * fn(a, b) +} diff --git a/frontend/src/components/library/applyBookSortFilter.test.js b/frontend/src/components/library/applyBookSortFilter.test.js new file mode 100644 index 0000000..1379b30 --- /dev/null +++ b/frontend/src/components/library/applyBookSortFilter.test.js @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest' +import { bookFilterPredicate, bookComparator } from './applyBookSortFilter' + +const books = [ + { + id: 'a', + title: 'Alpha', + year: 2015, + page_count: 100, + file_size: 10, + is_explicit: false, + genres: ['Fantasy'], + tags: ['osr'], + }, + { + id: 'b', + title: 'Beta', + year: 2020, + page_count: 40, + file_size: 50, + is_explicit: true, + genres: ['Horror'], + tags: ['grim'], + }, + { + id: 'c', + title: 'Gamma', + year: null, + page_count: 250, + file_size: 5, + is_explicit: false, + genres: [], + tags: ['osr', 'grim'], + }, +] + +describe('bookFilterPredicate', () => { + it('passes everything with no filters', () => { + const p = bookFilterPredicate({}) + expect(books.filter(p)).toHaveLength(3) + }) + + it('filters by explicit', () => { + const p = bookFilterPredicate({ explicit: true }) + expect(books.filter(p).map((b) => b.id)).toEqual(['b']) + }) + + it('filters by genre (AND)', () => { + const p = bookFilterPredicate({ genres: ['Fantasy'] }) + expect(books.filter(p).map((b) => b.id)).toEqual(['a']) + }) + + it('filters by multiple tags (AND)', () => { + const p = bookFilterPredicate({ tags: ['osr', 'grim'] }) + expect(books.filter(p).map((b) => b.id)).toEqual(['c']) + }) + + it('filters by favorites via isFavorite', () => { + const p = bookFilterPredicate({ favorites: true }, { isFavorite: (id) => id === 'b' }) + expect(books.filter(p).map((b) => b.id)).toEqual(['b']) + }) +}) + +describe('bookComparator', () => { + it('sorts by title ascending', () => { + const out = [...books].sort(bookComparator('title', 'asc')).map((b) => b.id) + expect(out).toEqual(['a', 'b', 'c']) + }) + + it('sorts by page_count descending', () => { + const out = [...books].sort(bookComparator('page_count', 'desc')).map((b) => b.id) + expect(out).toEqual(['c', 'a', 'b']) + }) + + it('sorts null years last (ascending)', () => { + const out = [...books].sort(bookComparator('year', 'asc')).map((b) => b.id) + expect(out[out.length - 1]).toBe('c') + }) +}) diff --git a/frontend/src/components/library/applySystemSortFilter.js b/frontend/src/components/library/applySystemSortFilter.js new file mode 100644 index 0000000..4b54cab --- /dev/null +++ b/frontend/src/components/library/applySystemSortFilter.js @@ -0,0 +1,56 @@ +// Client-side sort/filter for the systems list, matching the backend keys +// (name | book_count | page_count | year) and filters (genre, family, explicit, +// tags, favorites). + +const has = (field, wanted) => { + if (!wanted) return true + const w = String(wanted).toLowerCase() + if (Array.isArray(field)) return field.some((v) => String(v).toLowerCase() === w) + return String(field || '').toLowerCase() === w +} + +/** + * Filter + sort systems. + * @param systems array of system rows + * @param state { sort, order, filters } + * @param opts { isFavorite: (id) => bool } for the favorites filter + */ +export function applySystemSortFilter(systems, state, opts = {}) { + const { sort = 'name', order = 'asc', filters = {} } = state || {} + const { isFavorite } = opts + const wantTags = Array.isArray(filters.tags) ? filters.tags.map((tg) => tg.toLowerCase()) : [] + const wantDice = Array.isArray(filters.dice) ? filters.dice.map((d) => d.toLowerCase()) : [] + const search = (filters.search || '').trim().toLowerCase() + let out = systems.filter((s) => { + if (search && !(s.name || '').toLowerCase().includes(search)) return false + if (filters.explicit !== undefined && Boolean(s.is_explicit) !== filters.explicit) return false + if (filters.genre && !has(s.genres, filters.genre)) return false + if (filters.family && !has(s.system_family, filters.family)) return false + if (filters.parent_system && !has(s.parent_system, filters.parent_system)) return false + if (filters.edition && !has(s.edition, filters.edition)) return false + if (filters.favorites === true && isFavorite && !isFavorite(s.id)) return false + if (wantTags.length) { + const tags = (s.tags || []).map((tg) => String(tg).toLowerCase()) + if (!wantTags.every((tg) => tags.includes(tg))) return false + } + if (wantDice.length) { + const dice = (s.dice_materials || []).map((d) => String(d).toLowerCase()) + if (!wantDice.every((d) => dice.includes(d))) return false + } + return true + }) + const dir = order === 'desc' ? -1 : 1 + const cmp = { + name: (a, b) => a.name.localeCompare(b.name), + book_count: (a, b) => (a.book_count || 0) - (b.book_count || 0), + page_count: (a, b) => (a.total_page_count || 0) - (b.total_page_count || 0), + year: (a, b) => { + const ay = a.year == null ? Infinity : a.year + const by = b.year == null ? Infinity : b.year + return ay - by + }, + } + const fn = cmp[sort] || cmp.name + out = [...out].sort((a, b) => dir * fn(a, b)) + return out +} diff --git a/frontend/src/components/library/applySystemSortFilter.test.js b/frontend/src/components/library/applySystemSortFilter.test.js new file mode 100644 index 0000000..727ee2e --- /dev/null +++ b/frontend/src/components/library/applySystemSortFilter.test.js @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest' +import { applySystemSortFilter } from './applySystemSortFilter' + +const systems = [ + { + id: 'a', + name: 'Alpha', + book_count: 2, + total_page_count: 100, + year: 2015, + is_explicit: false, + genres: ['Fantasy'], + system_family: 'd20 System', + parent_system: 'Dungeons & Dragons', + edition: '5e', + }, + { + id: 'b', + name: 'Beta', + book_count: 5, + total_page_count: 40, + year: 2020, + is_explicit: true, + genres: ['Science Fiction'], + system_family: 'Fate', + }, + { + id: 'c', + name: 'Gamma', + book_count: 1, + total_page_count: 250, + year: null, + is_explicit: false, + genres: [], + system_family: '', + }, +] + +describe('applySystemSortFilter', () => { + it('sorts by name ascending by default', () => { + const out = applySystemSortFilter(systems, {}) + expect(out.map((s) => s.id)).toEqual(['a', 'b', 'c']) + }) + + it('sorts by book_count descending', () => { + const out = applySystemSortFilter(systems, { sort: 'book_count', order: 'desc' }) + expect(out.map((s) => s.id)).toEqual(['b', 'a', 'c']) + }) + + it('sorts by page_count ascending', () => { + const out = applySystemSortFilter(systems, { sort: 'page_count', order: 'asc' }) + expect(out.map((s) => s.id)).toEqual(['b', 'a', 'c']) + }) + + it('sorts null years last', () => { + const out = applySystemSortFilter(systems, { sort: 'year', order: 'asc' }) + expect(out[out.length - 1].id).toBe('c') + }) + + it('filters by genre', () => { + const out = applySystemSortFilter(systems, { filters: { genre: 'Fantasy' } }) + expect(out.map((s) => s.id)).toEqual(['a']) + }) + + it('filters by family', () => { + const out = applySystemSortFilter(systems, { filters: { family: 'Fate' } }) + expect(out.map((s) => s.id)).toEqual(['b']) + }) + + it('filters by parent_system', () => { + const out = applySystemSortFilter(systems, { + filters: { parent_system: 'Dungeons & Dragons' }, + }) + expect(out.map((s) => s.id)).toEqual(['a']) + }) + + it('filters by edition', () => { + const out = applySystemSortFilter(systems, { filters: { edition: '5e' } }) + expect(out.map((s) => s.id)).toEqual(['a']) + }) + + it('filters by explicit', () => { + const out = applySystemSortFilter(systems, { filters: { explicit: true } }) + expect(out.map((s) => s.id)).toEqual(['b']) + }) + + it('does not mutate the input array', () => { + const copy = [...systems] + applySystemSortFilter(systems, { sort: 'book_count', order: 'desc' }) + expect(systems).toEqual(copy) + }) +}) diff --git a/frontend/src/components/maps/InlineTagEditor.jsx b/frontend/src/components/maps/InlineTagEditor.jsx index e9debfa..6a04f65 100644 --- a/frontend/src/components/maps/InlineTagEditor.jsx +++ b/frontend/src/components/maps/InlineTagEditor.jsx @@ -1,188 +1,34 @@ -import { useState, useRef, useEffect } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { LuX } from 'react-icons/lu' - -export default function InlineTagEditor({ tags, onSave, onCancel, suggestions = [] }) { +import TagPicker from '../metadata/TagPicker' + +/** + * Inline tag editor for the media detail sidebars. Wraps the shared TagPicker so + * the dropdown (favorites-first, existing-tag suggestions) is consistent + * everywhere; saves on every change and offers a Done button to finish. Tags + * suggestions are scoped to `resourceType` (map/token/audio) when given. + */ +export default function InlineTagEditor({ tags, onSave, onCancel, resourceType = null }) { const { t } = useTranslation() const [draft, setDraft] = useState([...tags]) - const [input, setInput] = useState('') - const [activeIdx, setActiveIdx] = useState(-1) - const inputRef = useRef(null) - - useEffect(() => { - inputRef.current?.focus() - }, []) - - const filtered = - input.trim().length > 0 - ? suggestions.filter( - (s) => s.toLowerCase().includes(input.trim().toLowerCase()) && !draft.includes(s) - ) - : [] - - const commit = (value) => { - const tag = (value ?? input).trim().toLowerCase().replace(/,+$/, '') - setInput('') - setActiveIdx(-1) - if (tag && !draft.includes(tag)) { - const next = [...draft, tag] - setDraft(next) - onSave(next) - } - } - const remove = (tag) => { - const next = draft.filter((x) => x !== tag) + const update = (next) => { setDraft(next) onSave(next) } - const handleKey = (e) => { - if (filtered.length > 0) { - if (e.key === 'ArrowDown') { - e.preventDefault() - setActiveIdx((i) => Math.min(i + 1, filtered.length - 1)) - return - } - if (e.key === 'ArrowUp') { - e.preventDefault() - setActiveIdx((i) => Math.max(i - 1, -1)) - return - } - if (e.key === 'Enter' && activeIdx >= 0) { - e.preventDefault() - commit(filtered[activeIdx]) - return - } - } - if (e.key === 'Enter' || e.key === ',') { - e.preventDefault() - commit() - } else if (e.key === 'Escape') { - if (filtered.length > 0) { - setInput('') - setActiveIdx(-1) - } else onCancel() - } else if (e.key === 'Backspace' && !input && draft.length > 0) { - const next = draft.slice(0, -1) - setDraft(next) - onSave(next) - } - } - return ( -
- {draft.map((tag) => ( - - {tag} - - - ))} -
- { - setInput(e.target.value) - setActiveIdx(-1) - }} - onKeyDown={handleKey} - placeholder={t('inlineTagEditor.placeholder')} - style={{ - fontSize: 13, - padding: '2px 8px', - borderRadius: 10, - width: 120, - background: 'var(--bg-input)', - border: '1px solid var(--border)', - color: 'var(--text)', - }} - /> - {filtered.length > 0 && ( -
- {filtered.map((s, i) => ( -
{ - e.preventDefault() - commit(s) - }} - style={{ - padding: '6px 12px', - fontSize: 13, - cursor: 'pointer', - background: i === activeIdx ? 'var(--bg-card-hover)' : 'transparent', - color: 'var(--text)', - }} - > - {s} -
- ))} -
- )} +
+ +
+
-
) } -const editTagStyle = { - fontSize: 12, - padding: '2px 6px 2px 8px', - borderRadius: 10, - background: 'rgba(201, 168, 76, 0.15)', - border: '1px solid var(--gold-dim)', - color: 'var(--gold)', - display: 'inline-flex', - alignItems: 'center', -} - const cancelBtnStyle = { padding: '3px 10px', borderRadius: 6, diff --git a/frontend/src/components/maps/InlineTagEditor.test.jsx b/frontend/src/components/maps/InlineTagEditor.test.jsx new file mode 100644 index 0000000..05975c9 --- /dev/null +++ b/frontend/src/components/maps/InlineTagEditor.test.jsx @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import InlineTagEditor from './InlineTagEditor' + +// InlineTagEditor now wraps the shared TagPicker, which loads the tag catalog. +const mockList = vi.fn() +vi.mock('../../api', () => ({ + tags: { list: (...a) => mockList(...a) }, +})) + +beforeEach(() => { + vi.clearAllMocks() + mockList.mockResolvedValue({ tags: [] }) +}) + +describe('InlineTagEditor', () => { + it('renders existing tags as chips', () => { + render() + expect(screen.getByText('forest')).toBeInTheDocument() + expect(screen.getByText('cave')).toBeInTheDocument() + }) + + it('saves the updated list when a tag is added', async () => { + const onSave = vi.fn() + render() + await userEvent.type(screen.getByRole('combobox'), 'dungeon{Enter}') + expect(onSave).toHaveBeenCalledWith(['dungeon']) + }) + + it('saves when a tag is removed via its chip', async () => { + const onSave = vi.fn() + render() + await userEvent.click(screen.getByLabelText(/remove drop/i)) + expect(onSave).toHaveBeenCalledWith(['keep']) + }) + + it('calls onCancel from the Done button', async () => { + const onCancel = vi.fn() + render() + await userEvent.click(screen.getByText(/done/i)) + expect(onCancel).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/maps/MapDetailView.jsx b/frontend/src/components/maps/MapDetailView.jsx index 618c89e..f546137 100644 --- a/frontend/src/components/maps/MapDetailView.jsx +++ b/frontend/src/components/maps/MapDetailView.jsx @@ -386,6 +386,7 @@ export default function MapDetailView() { tags={currentFolderTags} onSave={saveFolderTags} onCancel={() => setEditingFolderTags(false)} + resourceType="map" />
) : ( @@ -417,6 +418,7 @@ export default function MapDetailView() { tags={map.tags} onSave={saveMapTags} onCancel={() => setEditingMapTags(false)} + resourceType="map" />
) : ( diff --git a/frontend/src/components/media/FolderTagRow.jsx b/frontend/src/components/media/FolderTagRow.jsx index e80a2f1..29187be 100644 --- a/frontend/src/components/media/FolderTagRow.jsx +++ b/frontend/src/components/media/FolderTagRow.jsx @@ -24,6 +24,7 @@ export default function FolderTagRow({ editing, canTag, i18n, + resourceType = null, fullLabels = false, onEdit, onSave, @@ -31,7 +32,14 @@ export default function FolderTagRow({ }) { const { t } = useTranslation() if (editing) { - return + return ( + + ) } return ( <> diff --git a/frontend/src/components/media/GalleryLayout.jsx b/frontend/src/components/media/GalleryLayout.jsx index 301bf20..1763f4f 100644 --- a/frontend/src/components/media/GalleryLayout.jsx +++ b/frontend/src/components/media/GalleryLayout.jsx @@ -1,8 +1,12 @@ import { useTranslation } from 'react-i18next' import GalleryToolbar from './GalleryToolbar' -import TagFilterBar from './TagFilterBar' import MediaFolderGroup from './MediaFolderGroup' +import MediaCard from './MediaCard' +import LazyGrid from '../LazyGrid' import BulkActionBar from '../BulkActionBar' +import SortFilterBar from '../library/SortFilterBar' +import SearchInput from '../library/SearchInput' +import useTagLabels, { titleCaseTag } from '../../hooks/useTagLabels' /** * Page layout shared by the media gallery views (maps, tokens). Renders the @@ -26,6 +30,20 @@ export default function GalleryLayout({ const { i18n, icon: Icon, emptyKey, emptyFilterKey } = config const { bulkMode } = gallery.bulk + const sortOptions = (config.sortOptions || ['name', 'size']).map((key) => ({ + value: key, + label: t(`sortFilter.sort${key.charAt(0).toUpperCase()}${key.slice(1)}`), + })) + // Tag filter options: values are the internal (lowercased) keys the gallery + // matches on; labels use the shared-tag display casing when available, falling + // back to Title Case for folder-only tags not in the shared-tag tables (#235). + const tagLabels = useTagLabels(config.type) + const tagOptions = gallery.allTags.map((tg) => ({ + value: tg, + label: tagLabels[tg] || titleCaseTag(tg), + })) + const handleSavePreset = (name, opts) => gallery.savedFilters.save(name, gallery.sortFilter, opts) + return (
- gallery.setCollapsed(gallery.allKeys)} - onExpandAll={() => gallery.setCollapsed(new Set())} - viewMode={gallery.viewMode} - onCycleViewMode={gallery.cycleViewMode} - favOnly={gallery.favOnly} - onToggleFavOnly={() => gallery.setFavOnly((v) => !v)} - /> + {/* Right-hand header panel: the search box sits in the top-right + corner (like the book search), with the view controls beneath it. */} +
+ {!bulkMode && ( + + )} + +
+ {/* Sort + filter toolbar (right-aligned), below the header. */} {!bulkMode && ( - +
+ +
)} {bulkMode && ( @@ -97,30 +139,60 @@ export default function GalleryLayout({

)} - {gallery.folderEntries.map(([folder, subfolders]) => ( - {} : gallery.setEditingFolder} - onSaveFolderTags={isPlayer ? () => {} : gallery.saveFolderTags} - canTag={!isPlayer} - onSelectItem={onSelectItem} - bulkMode={bulkMode} - selectedIds={gallery.selectedIds} - selectedFolderPaths={gallery.selectedFolderPaths} - onToggleItem={gallery.toggleSelect} - onToggleFolder={gallery.bulk.toggleFolder} - onDownload={onDownload} - /> - ))} + {/* Grouped by folder (default) or a single flat sorted grid. */} + {gallery.grouped + ? gallery.folderEntries.map(([folder, subfolders]) => ( + {} : gallery.setEditingFolder} + onSaveFolderTags={isPlayer ? () => {} : gallery.saveFolderTags} + canTag={!isPlayer} + onSelectItem={onSelectItem} + bulkMode={bulkMode} + selectedIds={gallery.selectedIds} + selectedFolderPaths={gallery.selectedFolderPaths} + onToggleItem={gallery.toggleSelect} + onToggleFolder={gallery.bulk.toggleFolder} + onDownload={onDownload} + /> + )) + : gallery.flatItems.length > 0 && ( + +
+ {gallery.flatItems.map((item) => ( + onSelectItem(item.id)} + bulkMode={bulkMode} + selected={gallery.selectedIds?.has(item.id)} + onToggle={(mods) => gallery.toggleSelect(item.id, mods)} + list={gallery.list} + /> + ))} +
+
+ )} {gallery.noFolders && (
diff --git a/frontend/src/components/media/GalleryLayout.test.jsx b/frontend/src/components/media/GalleryLayout.test.jsx index 2c9f1ff..f371138 100644 --- a/frontend/src/components/media/GalleryLayout.test.jsx +++ b/frontend/src/components/media/GalleryLayout.test.jsx @@ -3,26 +3,31 @@ import { render, screen, fireEvent } from '@testing-library/react' import GalleryLayout from './GalleryLayout' import { MEDIA_CONFIGS } from './mediaConfig' -// Fire the inline arrow handlers GalleryLayout passes down so they're covered. +// The toolbar has its own coverage; stub it so this test focuses on layout. vi.mock('./GalleryToolbar', () => ({ - default: ({ onCollapseAll, onExpandAll, onToggleFavOnly, onToggleBulk }) => ( -
-
- ), + default: ({ showBulk }) =>
, })) -vi.mock('./TagFilterBar', () => ({ default: () =>
})) vi.mock('./MediaFolderGroup', () => ({ default: ({ folder }) =>
{folder}
, })) +vi.mock('./MediaCard', () => ({ + default: ({ item, onClick, onToggle }) => ( + + ), +})) +vi.mock('../LazyGrid', () => ({ default: ({ children }) =>
{children}
})) vi.mock('../BulkActionBar', () => ({ default: () =>
})) const makeGallery = (over = {}) => ({ filter: '', setFilter: vi.fn(), + sortFilter: { sort: 'name', order: 'asc', filters: {} }, + setSortFilter: vi.fn(), + savedFilters: { saved: [], save: vi.fn(), setDefault: vi.fn(), remove: vi.fn(), loaded: true }, + grouped: true, + setGrouped: vi.fn(), bulk: { bulkMode: false, enter: vi.fn(), exit: vi.fn(), toggleFolder: vi.fn() }, noFolders: false, allCollapsed: false, @@ -32,12 +37,12 @@ const makeGallery = (over = {}) => ({ viewMode: 'grid', cycleViewMode: vi.fn(), favOnly: false, - setFavOnly: vi.fn(), allTags: [], selectedTags: new Set(), toggleTag: vi.fn(), clearTags: vi.fn(), folderEntries: [['Dungeons', {}]], + flatItems: [], cardSize: 'comfortable', list: false, collapsed: new Set(), @@ -69,43 +74,47 @@ const baseProps = (over = {}) => ({ }) describe('GalleryLayout', () => { - it('renders title, subtitle, toolbar, tag filter, and folder groups', () => { + it('renders title, subtitle, toolbar, and folder groups when grouped', () => { render() expect(screen.getByRole('heading', { name: 'Maps' })).toBeInTheDocument() expect(screen.getByText('Battle maps')).toBeInTheDocument() expect(screen.getByTestId('toolbar')).toBeInTheDocument() - expect(screen.getByTestId('tag-filter')).toBeInTheDocument() expect(screen.getByTestId('folder-group')).toHaveTextContent('Dungeons') expect(screen.queryByTestId('bulk-bar')).not.toBeInTheDocument() }) - it('wires the toolbar collapse/expand/favorite/bulk callbacks through', () => { - const gallery = makeGallery() - render() - fireEvent.click(screen.getByTestId('tb-collapse')) - expect(gallery.setCollapsed).toHaveBeenCalledWith(gallery.allKeys) - fireEvent.click(screen.getByTestId('tb-expand')) - expect(gallery.setCollapsed).toHaveBeenCalledWith(expect.any(Set)) - fireEvent.click(screen.getByTestId('tb-fav')) - expect(gallery.setFavOnly).toHaveBeenCalled() - fireEvent.click(screen.getByTestId('tb-bulk')) - expect(gallery.bulk.enter).toHaveBeenCalled() + it('renders a flat card grid (no folder groups) when grouping is off', () => { + const gallery = makeGallery({ + grouped: false, + flatItems: [ + { id: 'a', filename: 'goblin.png' }, + { id: 'b', filename: 'dragon.png' }, + ], + }) + const onSelectItem = vi.fn() + render() + expect(screen.queryByTestId('folder-group')).not.toBeInTheDocument() + const cards = screen.getAllByTestId('flat-card') + expect(cards.map((n) => n.textContent)).toEqual(['goblin.png', 'dragon.png']) + // Exercise the flat-card onClick / onToggle wiring. + fireEvent.click(cards[0]) + expect(onSelectItem).toHaveBeenCalledWith('a') + fireEvent.doubleClick(cards[1]) + expect(gallery.toggleSelect).toHaveBeenCalledWith('b', {}) }) - it('shows the bulk hint and bulk action bar in bulk mode (hiding the tag filter)', () => { + it('shows the bulk hint and bulk action bar in bulk mode', () => { const gallery = makeGallery({ bulk: { bulkMode: true, enter: vi.fn(), exit: vi.fn(), toggleFolder: vi.fn() }, }) render() expect(screen.getByTestId('bulk-bar')).toBeInTheDocument() - expect(screen.queryByTestId('tag-filter')).not.toBeInTheDocument() }) it('renders the empty state when there are no folders', () => { const gallery = makeGallery({ noFolders: true, folderEntries: [] }) render() expect(screen.queryByTestId('folder-group')).not.toBeInTheDocument() - // Empty message text comes from the maps config i18n keys (maps.noMaps). expect(screen.getByText(/No maps found/i)).toBeInTheDocument() }) @@ -116,7 +125,18 @@ describe('GalleryLayout', () => { expect(screen.getByText(/No maps match your filter/i)).toBeInTheDocument() }) - it('renders in player mode without crashing', () => { + it('shows the no-favourites message when favOnly is on and nothing matches', () => { + const gallery = makeGallery({ noFolders: true, folderEntries: [], favOnly: true }) + render() + expect(screen.getByText(/no favorites here yet/i)).toBeInTheDocument() + }) + + it('passes showBulk=false to the toolbar in player mode', () => { + render() + expect(screen.getByTestId('toolbar')).toHaveAttribute('data-showbulk', 'false') + }) + + it('renders folder groups in player mode', () => { render() expect(screen.getByTestId('folder-group')).toBeInTheDocument() }) diff --git a/frontend/src/components/media/GalleryToolbar.jsx b/frontend/src/components/media/GalleryToolbar.jsx index d719ac5..5399256 100644 --- a/frontend/src/components/media/GalleryToolbar.jsx +++ b/frontend/src/components/media/GalleryToolbar.jsx @@ -1,6 +1,8 @@ import { useTranslation } from 'react-i18next' -import { LuX, LuListChecks, LuSearch, LuHeart } from 'react-icons/lu' import ViewModeToggle from '../ViewModeToggle' +import BulkToggleButton from '../BulkToggleButton' +import CollapseExpandButtons from '../CollapseExpandButtons' +import ToggleSwitch from '../ToggleSwitch' const toolBtnStyle = { padding: '6px 12px', @@ -13,140 +15,51 @@ const toolBtnStyle = { } /** - * Header controls for a media gallery: filter input, collapse/expand-all, - * bulk-select toggle, view-mode toggle, and favorites-only toggle. - * Shared by the maps and tokens views via `config` (mediaConfig.js). + * Header view-controls for a media gallery (maps / tokens / audio): bulk-select, + * view-mode, a folder-grouping switch, and collapse/expand-all. The standalone + * search box and the SortFilterBar (sort + filter modal) are rendered separately + * by GalleryLayout, mirroring the system/library toolbar arrangement. */ -export default function GalleryToolbar({ - config, - filter, - onFilter, - bulkMode, - onToggleBulk, - showBulk, - collapseDisabled, - expandDisabled, - onCollapseAll, - onExpandAll, - viewMode, - onCycleViewMode, - favOnly, - onToggleFavOnly, -}) { +export default function GalleryToolbar({ config, gallery, showBulk }) { const { t } = useTranslation() const { i18n } = config + const { bulkMode } = gallery.bulk return (
- {!bulkMode && ( -
- - onFilter(e.target.value)} - aria-label={t(`${i18n}.filterAriaLabel`)} - style={{ - width: '100%', - fontSize: 13, - padding: '6px 28px 6px 30px', - borderRadius: 6, - border: '1px solid var(--border)', - background: 'var(--bg-card)', - boxSizing: 'border-box', - }} - /> - {filter && ( - - )} -
- )} -
- - - {showBulk && ( - - )} - - -
+ {showBulk && } + {bulkMode && } + + + {t('sortFilter.groupByFolder')} + + } + /> + gallery.setCollapsed(gallery.allKeys)} + onExpandAll={() => gallery.setCollapsed(new Set())} + collapseDisabled={gallery.noFolders || bulkMode || !gallery.grouped || gallery.allCollapsed} + expandDisabled={gallery.noFolders || bulkMode || !gallery.grouped || gallery.allExpanded} + />
) } diff --git a/frontend/src/components/media/GalleryToolbar.test.jsx b/frontend/src/components/media/GalleryToolbar.test.jsx new file mode 100644 index 0000000..e3cf2ff --- /dev/null +++ b/frontend/src/components/media/GalleryToolbar.test.jsx @@ -0,0 +1,82 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import GalleryToolbar from './GalleryToolbar' +import { MEDIA_CONFIGS } from './mediaConfig' + +const makeGallery = (over = {}) => ({ + bulk: { bulkMode: false, enter: vi.fn(), exit: vi.fn() }, + viewMode: 'grid', + cycleViewMode: vi.fn(), + grouped: true, + setGrouped: vi.fn(), + setCollapsed: vi.fn(), + allKeys: new Set(['a']), + noFolders: false, + allCollapsed: false, + allExpanded: false, + ...over, +}) + +const renderToolbar = (props = {}) => + render( + + ) + +describe('GalleryToolbar', () => { + it('renders the group-by-folder switch and view-mode control', () => { + renderToolbar() + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('enters bulk mode via the bulk-select button when allowed', async () => { + const enter = vi.fn() + renderToolbar({ gallery: { bulk: { bulkMode: false, enter, exit: vi.fn() } } }) + const btns = screen.getAllByRole('button') + // The first button is the bulk-select toggle. + await userEvent.click(btns[0]) + expect(enter).toHaveBeenCalled() + }) + + it('hides the bulk-select button for players (showBulk=false)', () => { + const { rerender } = renderToolbar({ showBulk: false }) + // Still renders the group switch even without bulk controls. + expect(screen.getByRole('switch')).toBeInTheDocument() + rerender() + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('toggles the folder grouping switch', async () => { + const setGrouped = vi.fn() + renderToolbar({ gallery: { setGrouped } }) + await userEvent.click(screen.getByRole('switch')) + expect(setGrouped).toHaveBeenCalled() + }) + + it('collapses all folders (setCollapsed with every key)', async () => { + const setCollapsed = vi.fn() + renderToolbar({ gallery: { setCollapsed, allKeys: new Set(['a', 'b']) } }) + await userEvent.click(screen.getByRole('button', { name: /collapse all/i })) + expect(setCollapsed).toHaveBeenCalledWith(new Set(['a', 'b'])) + }) + + it('expands all folders (setCollapsed with an empty set)', async () => { + const setCollapsed = vi.fn() + renderToolbar({ gallery: { setCollapsed, allCollapsed: true } }) + await userEvent.click(screen.getByRole('button', { name: /expand all/i })) + expect(setCollapsed).toHaveBeenCalledWith(new Set()) + }) + + it('exits bulk mode via the active bulk button', async () => { + const exit = vi.fn() + renderToolbar({ gallery: { bulk: { bulkMode: true, enter: vi.fn(), exit } } }) + // In bulk mode two bulk buttons render (enter + exit); the exit one calls exit(). + const btns = screen.getAllByRole('button') + await userEvent.click(btns[1]) + expect(exit).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/media/MediaFolderGroup.jsx b/frontend/src/components/media/MediaFolderGroup.jsx index a4beb71..01da17d 100644 --- a/frontend/src/components/media/MediaFolderGroup.jsx +++ b/frontend/src/components/media/MediaFolderGroup.jsx @@ -52,7 +52,7 @@ export default function MediaFolderGroup({ }) { const { t } = useTranslation() const isMobilePhone = useIsMobile(640) - const { i18n, archiveType, countKey } = config + const { i18n, archiveType, countKey, type } = config const { playQueue } = useAudioPlayer() const [editingRoot, setEditingRoot] = useState(false) const isCollapsed = collapsed.has(folder) @@ -203,6 +203,7 @@ export default function MediaFolderGroup({ editing={editingRoot} canTag={canTag} i18n={i18n} + resourceType={type} onEdit={() => setEditingRoot(true)} onSave={(newTags) => onSaveFolderTags(folder, newTags)} onCancel={() => setEditingRoot(false)} @@ -221,6 +222,7 @@ export default function MediaFolderGroup({ editing={editingRoot} canTag={canTag} i18n={i18n} + resourceType={type} fullLabels onEdit={() => setEditingRoot(true)} onSave={(newTags) => onSaveFolderTags(folder, newTags)} @@ -363,6 +365,7 @@ export default function MediaFolderGroup({ editing canTag={canTag} i18n={i18n} + resourceType={type} onSave={(newTags) => onSaveFolderTags(folderPath, newTags)} onCancel={() => onSetEditingFolder(null)} /> @@ -384,6 +387,7 @@ export default function MediaFolderGroup({ editing={editingFolder === editKey} canTag={canTag} i18n={i18n} + resourceType={type} fullLabels onEdit={() => onSetEditingFolder(editKey)} onSave={(newTags) => onSaveFolderTags(folderPath, newTags)} diff --git a/frontend/src/components/media/TagFilterBar.test.jsx b/frontend/src/components/media/TagFilterBar.test.jsx new file mode 100644 index 0000000..69dc0af --- /dev/null +++ b/frontend/src/components/media/TagFilterBar.test.jsx @@ -0,0 +1,60 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import TagFilterBar from './TagFilterBar' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (k, o) => + ({ + 'common.tags': 'Tags:', + 'common.clear': 'Clear', + 'common.showLess': 'Show less', + 'common.showMore': `+${o?.count} more`, + })[k] || k, + }), +})) + +describe('TagFilterBar', () => { + it('renders nothing when there are no tags', () => { + const { container } = render( + + ) + expect(container.firstChild).toBeNull() + }) + + it('renders capitalized tag pills and toggles one', async () => { + const onToggle = vi.fn() + render( + + ) + const pill = screen.getByText('Forest') + await userEvent.click(pill) + expect(onToggle).toHaveBeenCalledWith('forest') + }) + + it('shows a clear button only when something is selected', async () => { + const onClear = vi.fn() + render( + + ) + await userEvent.click(screen.getByText('Clear')) + expect(onClear).toHaveBeenCalled() + }) + + it('limits visible tags and reveals the rest via show-more', async () => { + const tags = Array.from({ length: 20 }, (_, i) => `tag${i}`) + render() + // 16th+ tags hidden until "+5 more" is clicked. + expect(screen.queryByText('Tag19')).not.toBeInTheDocument() + await userEvent.click(screen.getByText('+5 more')) + expect(screen.getByText('Tag19')).toBeInTheDocument() + await userEvent.click(screen.getByText('Show less')) + expect(screen.queryByText('Tag19')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/media/mediaConfig.js b/frontend/src/components/media/mediaConfig.js index a8e983f..4a2d613 100644 --- a/frontend/src/components/media/mediaConfig.js +++ b/frontend/src/components/media/mediaConfig.js @@ -34,6 +34,7 @@ export const MEDIA_CONFIGS = { downloadType: 'maps', archiveType: 'map_folder', sessionKey: 'grimoire:maps:collapsed', + sortOptions: ['name', 'size'], // Grid cell min width per card size. gridMin: { comfortable: '200px', compact: '140px' }, gridGap: 16, @@ -69,6 +70,7 @@ export const MEDIA_CONFIGS = { downloadType: 'tokens', archiveType: 'token_folder', sessionKey: 'grimoire:tokens:collapsed', + sortOptions: ['name', 'size'], gridMin: { comfortable: '130px', compact: '90px' }, gridGap: 12, thumb: { kind: 'square' }, @@ -109,6 +111,7 @@ export const MEDIA_CONFIGS = { downloadType: 'audio', archiveType: 'audio_folder', sessionKey: 'grimoire:audio:collapsed', + sortOptions: ['title', 'name', 'duration', 'size'], gridMin: { comfortable: '200px', compact: '140px' }, gridGap: 16, thumb: { kind: 'square' }, diff --git a/frontend/src/components/metadata/CategoryPicker.jsx b/frontend/src/components/metadata/CategoryPicker.jsx new file mode 100644 index 0000000..06a2e02 --- /dev/null +++ b/frontend/src/components/metadata/CategoryPicker.jsx @@ -0,0 +1,143 @@ +import { useState, useRef, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { slugify } from '../../constants' + +/** + * Single-value combobox for a book category, matching the GenrePicker feel: one + * input that filters the known options as you type (shown by their friendly + * label), lets you pick one, or create a brand-new custom category. The stored + * value is always a slug; `options` is `[{ value: slug, label }]`. + */ +export default function CategoryPicker({ value, onChange, options }) { + const { t } = useTranslation() + const [query, setQuery] = useState('') + const [editing, setEditing] = useState(false) + const [open, setOpen] = useState(false) + const [activeIdx, setActiveIdx] = useState(0) + const wrapRef = useRef(null) + + const labelFor = (slug) => options.find((o) => o.value === slug)?.label || slug + + useEffect(() => { + const onDoc = (e) => { + if (wrapRef.current && !wrapRef.current.contains(e.target)) { + setOpen(false) + setEditing(false) + setQuery('') + } + } + document.addEventListener('mousedown', onDoc) + return () => document.removeEventListener('mousedown', onDoc) + }, []) + + const q = query.trim().toLowerCase() + const matches = options.filter( + (o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q) + ) + const slugQ = slugify(query) + const exists = options.some((o) => o.value === slugQ) + const canCreate = slugQ.length > 0 && !exists + + const rows = [ + ...matches.map((o) => ({ type: 'option', ...o })), + ...(canCreate ? [{ type: 'create', value: slugQ, label: query.trim() }] : []), + ] + + const commit = (row) => { + if (!row) return + onChange(row.value) + setEditing(false) + setOpen(false) + setQuery('') + } + + const onKeyDown = (e) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setOpen(true) + setActiveIdx((i) => Math.min(i + 1, rows.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIdx((i) => Math.max(i - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + commit(rows[activeIdx] || rows[0]) + } else if (e.key === 'Escape') { + setEditing(false) + setOpen(false) + setQuery('') + } + } + + return ( +
+ { + setEditing(true) + setQuery('') + setOpen(true) + setActiveIdx(0) + }} + onChange={(e) => { + setEditing(true) + setQuery(e.target.value) + setOpen(true) + setActiveIdx(0) + }} + onKeyDown={onKeyDown} + placeholder={t('bookEditor.categoryPlaceholder')} + style={{ width: '100%', fontSize: 13 }} + /> + {open && rows.length > 0 && ( +
+ {rows.map((row, i) => ( + + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/metadata/CategoryPicker.test.jsx b/frontend/src/components/metadata/CategoryPicker.test.jsx new file mode 100644 index 0000000..871b3c1 --- /dev/null +++ b/frontend/src/components/metadata/CategoryPicker.test.jsx @@ -0,0 +1,100 @@ +import { describe, it, expect, vi } from 'vitest' +import { useState } from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import CategoryPicker from './CategoryPicker' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (k, o) => (o ? `${k}:${o.name}` : k) }), +})) + +const options = [ + { value: 'core', label: 'Core Rulebooks' }, + { value: 'adventure', label: 'Adventures & Modules' }, + { value: 'my-custom', label: 'my-custom' }, +] + +function Harness({ initial = 'core' }) { + const [value, setValue] = useState(initial) + return ( +
+ + {value} +
+ ) +} + +describe('CategoryPicker', () => { + it('shows the friendly label for the current slug when not editing', () => { + render() + expect(screen.getByRole('combobox').value).toBe('Core Rulebooks') + }) + + it('lists options on focus and filters as you type', async () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + expect(screen.getAllByRole('option').length).toBeGreaterThanOrEqual(3) + fireEvent.change(input, { target: { value: 'advent' } }) + const labels = screen.getAllByRole('option').map((o) => o.textContent) + expect(labels).toContain('Adventures & Modules') + expect(labels).not.toContain('Core Rulebooks') + }) + + it('commits the picked slug (not the label)', async () => { + render() + fireEvent.focus(screen.getByRole('combobox')) + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'advent' } }) + await userEvent.click(screen.getByRole('option', { name: 'Adventures & Modules' })) + expect(screen.getByTestId('val').textContent).toBe('adventure') + }) + + it('offers a create row for a brand-new value, slugified', async () => { + render() + fireEvent.focus(screen.getByRole('combobox')) + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'My New Cat!' } }) + await userEvent.click(screen.getByRole('option', { name: /createCategory/ })) + expect(screen.getByTestId('val').textContent).toBe('my-new-cat') + }) + + it('does not offer create for an existing slug', () => { + render() + fireEvent.focus(screen.getByRole('combobox')) + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'core' } }) + expect(screen.queryByRole('option', { name: /createCategory/ })).not.toBeInTheDocument() + }) + + it('commits the active option on Enter after arrow navigation', async () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.keyDown(input, { key: 'ArrowDown' }) + fireEvent.keyDown(input, { key: 'ArrowUp' }) + fireEvent.keyDown(input, { key: 'Enter' }) + // Enter commits some option (a valid slug). + expect(['core', 'adventure', 'my-custom']).toContain(screen.getByTestId('val').textContent) + }) + + it('closes the list on Escape without changing the value', () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'advent' } }) + fireEvent.keyDown(input, { key: 'Escape' }) + expect(screen.queryByRole('option')).not.toBeInTheDocument() + expect(screen.getByTestId('val').textContent).toBe('core') + }) + + it('closes the dropdown when clicking outside', () => { + render( +
+ + +
+ ) + fireEvent.focus(screen.getByRole('combobox')) + expect(screen.getAllByRole('option').length).toBeGreaterThan(0) + fireEvent.mouseDown(screen.getByText('outside')) + expect(screen.queryByRole('option')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/metadata/DiceMaterialsPicker.jsx b/frontend/src/components/metadata/DiceMaterialsPicker.jsx new file mode 100644 index 0000000..aeeb877 --- /dev/null +++ b/frontend/src/components/metadata/DiceMaterialsPicker.jsx @@ -0,0 +1,263 @@ +import { useState, useRef, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { LuX } from 'react-icons/lu' +import api from '../../api' +import { buildDiceMaterialRows } from './diceMaterials' + +/** + * Combobox multi-select for a system's dice / materials, modeled on GenrePicker. + * Selected values show as removable chips. Typing filters a curated, grouped + * list; group headers are shown but not selectable. Text that matches no option + * offers a "Create «text»" row for a custom value. + * + * The option groups come from the managed dice/materials lookup when a `groups` + * prop is supplied (built via `groupsFromManaged`), otherwise the built-in + * defaults are used. Creating a custom value best-effort persists it to the + * managed list (admin only) and calls `onCreate` to refresh, mirroring + * GenrePicker; without `onCreate` it simply adds the free-text value. + */ +export default function DiceMaterialsPicker({ selected, onChange, groups, onCreate }) { + const { t } = useTranslation() + const [query, setQuery] = useState('') + const [open, setOpen] = useState(false) + const [activeIdx, setActiveIdx] = useState(0) + const wrapRef = useRef(null) + + const has = (name) => selected.some((g) => g.toLowerCase() === name.toLowerCase()) + + useEffect(() => { + const onDoc = (e) => { + if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false) + } + document.addEventListener('mousedown', onDoc) + return () => document.removeEventListener('mousedown', onDoc) + }, []) + + const q = query.trim().toLowerCase() + // Grouped rows, dropping already-selected items and (when filtering) any group + // that ends up with no matching items. `groups` (when supplied) sources the + // options from the managed dice/materials lookup; otherwise built-in defaults. + const allRows = groups + ? buildDiceMaterialRows(selected, t('metadata.diceCustomGroup'), groups) + : buildDiceMaterialRows(selected, t('metadata.diceCustomGroup')) + const grouped = [] + for (const row of allRows) { + if (row.type === 'group') { + grouped.push({ header: row, items: [] }) + } else if (!has(row.value) && (!q || row.value.toLowerCase().includes(q))) { + grouped[grouped.length - 1]?.items.push(row) + } + } + const visibleGroups = grouped.filter((g) => g.items.length > 0) + + const exact = allRows.some((r) => r.type === 'item' && r.value.toLowerCase() === q) + const canCreate = q.length > 0 && !exact + + // Flat list of selectable option rows (for keyboard nav), plus an optional + // create row. Group headers are rendered but excluded from this list. + const optionRows = [ + ...visibleGroups.flatMap((g) => g.items), + ...(canCreate ? [{ type: 'create', value: query.trim() }] : []), + ] + + const add = (value) => { + const v = value.trim() + if (v && !has(v)) onChange([...selected, v]) + } + + // Best-effort: persist a newly created value to the managed lookup so it shows + // up in the list later. Ignored if not permitted (non-admin) or already exists. + const persistCustom = (value) => { + const v = value.trim() + if (!v) return + const known = allRows.some( + (r) => r.type === 'item' && r.value.toLowerCase() === v.toLowerCase() + ) + if (known) return + api + .post('/dice-materials', { name: v, group: 'Custom' }) + .then((created) => onCreate && onCreate(created)) + .catch(() => {}) + } + + const choose = (row) => { + if (!row) return + if (row.type === 'create' && onCreate) persistCustom(row.value) + add(row.value) + setQuery('') + setActiveIdx(0) + setOpen(true) + } + + const onKeyDown = (e) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setOpen(true) + setActiveIdx((i) => Math.min(i + 1, optionRows.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIdx((i) => Math.max(i - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + choose(optionRows[activeIdx] || optionRows[0]) + } else if (e.key === 'Backspace' && !query && selected.length > 0) { + onChange(selected.slice(0, -1)) + } else if (e.key === 'Escape') { + setOpen(false) + } + } + + // Global index into optionRows (for highlight), tracked as we render. + let optIdx = -1 + + return ( +
+
+ {selected.length === 0 && ( + + {t('metadata.noDiceMaterials')} + + )} + {selected.map((g) => ( + + {g} + + + ))} +
+ { + setQuery(e.target.value) + setOpen(true) + setActiveIdx(0) + }} + onFocus={() => setOpen(true)} + onKeyDown={onKeyDown} + placeholder={t('metadata.diceMaterialsComboPlaceholder')} + style={{ width: '100%' }} + /> + {open && optionRows.length > 0 && ( +
+ {visibleGroups.map((g) => ( +
+
+ {g.header.label} +
+ {g.items.map((row) => { + optIdx += 1 + const idx = optIdx + return ( + + ) + })} +
+ ))} + {canCreate && + (() => { + optIdx += 1 + const idx = optIdx + return ( + + ) + })()} +
+ )} +
+ ) +} diff --git a/frontend/src/components/metadata/DiceMaterialsPicker.test.jsx b/frontend/src/components/metadata/DiceMaterialsPicker.test.jsx new file mode 100644 index 0000000..9e61191 --- /dev/null +++ b/frontend/src/components/metadata/DiceMaterialsPicker.test.jsx @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useState } from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import DiceMaterialsPicker from './DiceMaterialsPicker' +import api from '../../api' +import { groupsFromManaged } from './diceMaterials' + +vi.mock('../../api', () => ({ default: { post: vi.fn(() => Promise.resolve({ id: 'x' })) } })) + +beforeEach(() => vi.clearAllMocks()) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (k, o) => + ({ + 'metadata.noDiceMaterials': 'No dice/materials selected', + 'metadata.addDiceMaterial': 'Add dice/material', + 'metadata.diceMaterialsComboPlaceholder': 'Search or add dice/materials…', + 'metadata.createDiceMaterial': `Create "${o?.name}"`, + 'metadata.diceCustomGroup': 'Custom', + })[k] || k, + }), +})) + +function Harness({ initial = [], groups, onCreate }) { + const [selected, setSelected] = useState(initial) + return ( +
+ + {JSON.stringify(selected)} +
+ ) +} + +describe('DiceMaterialsPicker', () => { + it('shows the empty placeholder', () => { + render() + expect(screen.getByText('No dice/materials selected')).toBeInTheDocument() + }) + + it('shows group headers (unselectable) and default items on focus', async () => { + render() + await userEvent.click(screen.getByRole('combobox')) + // Group headers render but are not options (role=presentation, not option). + expect(screen.getByText('Dice')).toBeInTheDocument() + expect(screen.getByText('Cards')).toBeInTheDocument() + // Items are options. + expect(screen.getByRole('option', { name: 'D20' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'Tarot Cards' })).toBeInTheDocument() + }) + + it('adds a default item by clicking it', async () => { + render() + await userEvent.click(screen.getByRole('combobox')) + await userEvent.click(screen.getByRole('option', { name: 'D20' })) + expect(screen.getByTestId('sel').textContent).toBe('["D20"]') + }) + + it('filters items as you type and adds on Enter', () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'tarot' } }) + expect(screen.getByRole('option', { name: 'Tarot Cards' })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: 'D20' })).not.toBeInTheDocument() + fireEvent.keyDown(input, { key: 'Enter' }) + expect(screen.getByTestId('sel').textContent).toBe('["Tarot Cards"]') + }) + + it('offers a create row for a custom value', async () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'Glass Beads' } }) + await userEvent.click(screen.getByRole('option', { name: /Create "Glass Beads"/ })) + expect(screen.getByTestId('sel').textContent).toBe('["Glass Beads"]') + }) + + it('removes a selected chip', async () => { + render() + await userEvent.click(screen.getByLabelText('Remove D6')) + expect(screen.getByTestId('sel').textContent).toBe('[]') + }) + + it('sources options from a managed group list when provided', async () => { + const groups = groupsFromManaged([ + { name: 'Fudge Dice', group: 'Dice' }, + { name: 'Runestones', group: 'Custom' }, + ]) + render() + await userEvent.click(screen.getByRole('combobox')) + expect(screen.getByRole('option', { name: 'Fudge Dice' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'Runestones' })).toBeInTheDocument() + // Built-in defaults are NOT shown when a managed list is supplied. + expect(screen.queryByRole('option', { name: 'D20' })).not.toBeInTheDocument() + }) + + it('persists a created custom value and calls onCreate when provided', async () => { + const onCreate = vi.fn() + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'Runestones' } }) + await userEvent.click(screen.getByRole('option', { name: /Create "Runestones"/ })) + expect(screen.getByTestId('sel').textContent).toBe('["Runestones"]') + await waitFor(() => + expect(api.post).toHaveBeenCalledWith('/dice-materials', { + name: 'Runestones', + group: 'Custom', + }) + ) + await waitFor(() => expect(onCreate).toHaveBeenCalled()) + }) + + it('does not POST when creating without onCreate (free-text only)', async () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'Glass Beads' } }) + await userEvent.click(screen.getByRole('option', { name: /Create "Glass Beads"/ })) + expect(screen.getByTestId('sel').textContent).toBe('["Glass Beads"]') + expect(api.post).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/metadata/GenrePicker.jsx b/frontend/src/components/metadata/GenrePicker.jsx new file mode 100644 index 0000000..631ce7d --- /dev/null +++ b/frontend/src/components/metadata/GenrePicker.jsx @@ -0,0 +1,228 @@ +import { useState, useRef, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { LuX } from 'react-icons/lu' +import api from '../../api' +import { buildGenreTree } from './metadataUtils' + +/** + * Tiered, single-input combobox multi-select for genres. Selected genres show + * as removable chips. Typing in the one input filters the curated tiered list + * (indented by depth) as a dropdown; picking an option adds it. If the typed + * text matches no existing genre, a "Create «text»" row lets you add a custom + * genre, which is also persisted to the lookup list (best-effort) so it appears + * next time. + */ +export default function GenrePicker({ + genreTree, + selected, + onChange, + onGenreCreated, + inheritGenres = null, +}) { + const { t } = useTranslation() + const [query, setQuery] = useState('') + const [open, setOpen] = useState(false) + const [activeIdx, setActiveIdx] = useState(0) + const wrapRef = useRef(null) + + const tree = buildGenreTree(genreTree) + const has = (name) => selected.some((g) => g.toLowerCase() === name.toLowerCase()) + + // "Inherit from system": merge the system's genres into the book's, keeping + // any extras the book already has and never duplicating (case-insensitive). + const inheritable = (inheritGenres || []).filter((g) => !has(g)) + const inheritFromSystem = () => { + if (inheritable.length) onChange([...selected, ...inheritable]) + } + + // Close the dropdown when clicking outside. + useEffect(() => { + const onDoc = (e) => { + if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false) + } + document.addEventListener('mousedown', onDoc) + return () => document.removeEventListener('mousedown', onDoc) + }, []) + + const q = query.trim().toLowerCase() + // Options not already selected, filtered by the query (substring match). + const matches = tree.filter((g) => !has(g.name) && g.name.toLowerCase().includes(q)) + const exact = tree.some((g) => g.name.toLowerCase() === q) + const canCreate = q.length > 0 && !exact + + // Build the concrete option rows (existing matches, then an optional create row). + const rows = [ + ...matches.map((g) => ({ type: 'genre', ...g })), + ...(canCreate ? [{ type: 'create', name: query.trim() }] : []), + ] + + const addGenre = (name) => { + const v = name.trim() + if (v && !has(v)) onChange([...selected, v]) + } + + const createGenre = (name) => { + const v = name.trim() + if (!v) return + addGenre(v) + // Best-effort: persist as a lookup value so it shows in the list later. + // Ignored if not permitted (non-admin) or already exists. + if (!tree.some((g) => g.name.toLowerCase() === v.toLowerCase())) { + api + .post('/genres', { name: v }) + .then((created) => onGenreCreated && onGenreCreated(created)) + .catch(() => {}) + } + } + + const choose = (row) => { + if (!row) return + if (row.type === 'create') createGenre(row.name) + else addGenre(row.name) + setQuery('') + setActiveIdx(0) + setOpen(true) + } + + const onKeyDown = (e) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setOpen(true) + setActiveIdx((i) => Math.min(i + 1, rows.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIdx((i) => Math.max(i - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + choose(rows[activeIdx] || rows[0]) + } else if (e.key === 'Backspace' && !query && selected.length > 0) { + onChange(selected.slice(0, -1)) + } else if (e.key === 'Escape') { + setOpen(false) + } + } + + return ( +
+
+ {selected.length === 0 && ( + {t('metadata.noGenres')} + )} + {selected.map((g) => ( + + {g} + + + ))} +
+ { + setQuery(e.target.value) + setOpen(true) + setActiveIdx(0) + }} + onFocus={() => setOpen(true)} + onKeyDown={onKeyDown} + placeholder={t('metadata.genreComboPlaceholder')} + style={{ width: '100%' }} + /> + {inheritGenres !== null && ( + + )} + {open && rows.length > 0 && ( +
+ {rows.map((row, i) => ( + + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/metadata/GenrePicker.test.jsx b/frontend/src/components/metadata/GenrePicker.test.jsx new file mode 100644 index 0000000..3f21b1c --- /dev/null +++ b/frontend/src/components/metadata/GenrePicker.test.jsx @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useState } from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import GenrePicker from './GenrePicker' +import api from '../../api' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (k, o) => (o ? `${k}:${o.name}` : k) }), +})) +vi.mock('../../api', () => ({ default: { post: vi.fn(() => Promise.resolve({ id: 'new' })) } })) + +const tree = [ + { id: 'sci', name: 'Science Fiction', parent_id: null, sort_order: 1 }, + { id: 'cyber', name: 'Cyberpunk', parent_id: 'sci', sort_order: 1 }, +] + +function Harness({ initial = [], inheritGenres = null }) { + const [selected, setSelected] = useState(initial) + return ( + + ) +} + +beforeEach(() => vi.clearAllMocks()) + +describe('GenrePicker', () => { + it('shows the empty placeholder', () => { + render() + expect(screen.getByText('metadata.noGenres')).toBeInTheDocument() + }) + + it('opens a filtered list on focus and adds a genre by clicking', async () => { + render() + await userEvent.click(screen.getByRole('combobox')) + // Clicking an option adds it as a chip. + await userEvent.click(screen.getByRole('option', { name: /Cyberpunk/ })) + expect(screen.getByText('Cyberpunk')).toBeInTheDocument() + }) + + it('filters the list as you type', async () => { + render() + await userEvent.type(screen.getByRole('combobox'), 'cyber') + // Science Fiction is filtered out; Cyberpunk matches (plus a create row). + expect(screen.getByRole('option', { name: /Cyberpunk/ })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: /Science Fiction/ })).not.toBeInTheDocument() + }) + + it('indents child options in the list', async () => { + render() + await userEvent.click(screen.getByRole('combobox')) + const cyber = screen.getByRole('option', { name: /Cyberpunk/ }) + expect(cyber.textContent).toContain('└') + }) + + it('offers a create row and persists a custom genre via the API', async () => { + render() + await userEvent.type(screen.getByRole('combobox'), 'Solarpunk') + // The create row appears (label uses createGenre:{{name}}). + await userEvent.click(screen.getByRole('option', { name: /createGenre:Solarpunk/ })) + expect(screen.getByText('Solarpunk')).toBeInTheDocument() + expect(api.post).toHaveBeenCalledWith('/genres', { name: 'Solarpunk' }) + }) + + it('does not offer create for an existing genre name', async () => { + render() + await userEvent.type(screen.getByRole('combobox'), 'Cyberpunk') + expect(screen.queryByRole('option', { name: /createGenre/ })).not.toBeInTheDocument() + }) + + it('adds the active option on Enter', async () => { + render() + await userEvent.type(screen.getByRole('combobox'), 'science{Enter}') + expect(screen.getByText('Science Fiction')).toBeInTheDocument() + }) + + it('removes a selected genre via its chip button', async () => { + render() + await userEvent.click(screen.getByLabelText('Remove Fantasy')) + expect(screen.queryByText('Fantasy')).not.toBeInTheDocument() + }) + + it('removes the last chip on Backspace when the input is empty', async () => { + render() + const input = screen.getByRole('combobox') + input.focus() + await userEvent.keyboard('{Backspace}') + expect(screen.queryByText('Horror')).not.toBeInTheDocument() + expect(screen.getByText('Fantasy')).toBeInTheDocument() + }) + + it('does not show the inherit button when inheritGenres is null', () => { + render() + expect(screen.queryByText('metadata.inheritFromSystem')).not.toBeInTheDocument() + }) + + it('merges system genres on inherit, keeping extras and avoiding duplicates', async () => { + // Book already has "Horror" (an extra) and "Fantasy" (also on the system). + render() + await userEvent.click(screen.getByText('metadata.inheritFromSystem')) + // Fantasy not duplicated; Horror kept; Sci-Fi added. + expect(screen.getAllByText('Fantasy')).toHaveLength(1) + expect(screen.getByText('Horror')).toBeInTheDocument() + expect(screen.getByText('Sci-Fi')).toBeInTheDocument() + }) + + it('disables the inherit button when nothing new to add', () => { + render() + expect(screen.getByText('metadata.inheritFromSystem')).toBeDisabled() + }) +}) diff --git a/frontend/src/components/metadata/LinkListEditor.jsx b/frontend/src/components/metadata/LinkListEditor.jsx new file mode 100644 index 0000000..ab24234 --- /dev/null +++ b/frontend/src/components/metadata/LinkListEditor.jsx @@ -0,0 +1,81 @@ +import { LuX, LuPlus } from 'react-icons/lu' + +/** + * Repeatable list of labeled links ([{ label, url }]). Used for a system's + * generic URLs and character-builder URLs, and a book's URLs. Empty rows are + * filtered out by the caller on save (see cleanLinks). + */ +export default function LinkListEditor({ + links, + onChange, + addLabel, + labelPlaceholder, + urlPlaceholder, + idPrefix = 'link', +}) { + const setLink = (idx, key, value) => + onChange(links.map((l, i) => (i === idx ? { ...l, [key]: value } : l))) + + const addLink = () => onChange([...links, { label: '', url: '' }]) + const removeLink = (idx) => onChange(links.filter((_, i) => i !== idx)) + + return ( +
+ {links.map((l, idx) => ( +
+ setLink(idx, 'label', e.target.value)} + placeholder={labelPlaceholder} + aria-label={labelPlaceholder} + style={{ flex: '1 1 130px', minWidth: 0 }} + /> + setLink(idx, 'url', e.target.value)} + placeholder={urlPlaceholder} + aria-label={urlPlaceholder} + style={{ flex: '1 1 180px', minWidth: 0 }} + /> + +
+ ))} + +
+ ) +} diff --git a/frontend/src/components/metadata/LinkListEditor.test.jsx b/frontend/src/components/metadata/LinkListEditor.test.jsx new file mode 100644 index 0000000..851ec78 --- /dev/null +++ b/frontend/src/components/metadata/LinkListEditor.test.jsx @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest' +import { useState } from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import LinkListEditor from './LinkListEditor' + +function Harness({ initial = [{ label: '', url: '' }] }) { + const [links, setLinks] = useState(initial) + return ( + + ) +} + +describe('LinkListEditor', () => { + it('edits label and url', async () => { + render() + await userEvent.type(document.getElementById('lnk-label-0'), 'DTRPG') + await userEvent.type(document.getElementById('lnk-url-0'), 'http://x') + expect(document.getElementById('lnk-label-0').value).toBe('DTRPG') + expect(document.getElementById('lnk-url-0').value).toBe('http://x') + }) + + it('adds a new row', async () => { + render() + await userEvent.click(screen.getByText('Add Link')) + expect(document.getElementById('lnk-url-1')).toBeTruthy() + }) + + it('removes a row', async () => { + render( + + ) + await userEvent.click(screen.getAllByLabelText('Remove link')[0]) + // Only one row remains. + expect(document.getElementById('lnk-url-1')).toBeFalsy() + }) +}) diff --git a/frontend/src/components/metadata/LookupCombobox.jsx b/frontend/src/components/metadata/LookupCombobox.jsx new file mode 100644 index 0000000..9f545c6 --- /dev/null +++ b/frontend/src/components/metadata/LookupCombobox.jsx @@ -0,0 +1,25 @@ +/** + * A single-value text input backed by a datalist of known options — the user + * can pick an existing value or type a new one. Used for System Family. + */ +export default function LookupCombobox({ id, value, onChange, options, placeholder = '' }) { + const listId = `${id}-options` + return ( + <> + onChange(e.target.value)} + placeholder={placeholder} + style={{ width: '100%' }} + /> + + {options.map((o) => ( + + + ) +} diff --git a/frontend/src/components/metadata/LookupCombobox.test.jsx b/frontend/src/components/metadata/LookupCombobox.test.jsx new file mode 100644 index 0000000..33e473d --- /dev/null +++ b/frontend/src/components/metadata/LookupCombobox.test.jsx @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import { useState } from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import LookupCombobox from './LookupCombobox' + +function Harness() { + const [v, setV] = useState('') + return ( + + ) +} + +describe('LookupCombobox', () => { + it('renders options in a datalist', () => { + render() + const list = document.getElementById('fam-options') + expect(list.querySelectorAll('option')).toHaveLength(2) + }) + + it('accepts typed custom values', async () => { + render() + await userEvent.type(screen.getByPlaceholderText('family'), 'Custom Engine') + expect(screen.getByPlaceholderText('family').value).toBe('Custom Engine') + }) +}) diff --git a/frontend/src/components/metadata/MultiSelectDropdown.jsx b/frontend/src/components/metadata/MultiSelectDropdown.jsx new file mode 100644 index 0000000..da788af --- /dev/null +++ b/frontend/src/components/metadata/MultiSelectDropdown.jsx @@ -0,0 +1,186 @@ +import { useState, useRef, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { LuChevronDown, LuSearch, LuX } from 'react-icons/lu' + +/** + * A searchable multi-select dropdown. Shows a trigger with the count of + * selected values; opening reveals a search box and a scrollable checkbox list. + * Scales cleanly to long option lists (e.g. 50+ tags) unlike an inline pill row. + * + * Props: + * - options: [{ value, label }] + * - selected: string[] + * - onChange: (nextSelected) => void + * - label: trigger/aria label + * - emptyLabel: shown when there are no options at all + * - searchPlaceholder + */ +export default function MultiSelectDropdown({ + options, + selected = [], + onChange, + label, + emptyLabel = '—', + searchPlaceholder, +}) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const wrapRef = useRef(null) + + useEffect(() => { + const onDoc = (e) => { + if (wrapRef.current && !wrapRef.current.contains(e.target)) { + setOpen(false) + setQuery('') + } + } + document.addEventListener('mousedown', onDoc) + return () => document.removeEventListener('mousedown', onDoc) + }, []) + + const toggle = (value) => { + const next = selected.includes(value) + ? selected.filter((v) => v !== value) + : [...selected, value] + onChange(next.length ? next : []) + } + + const q = query.trim().toLowerCase() + const filtered = q ? options.filter((o) => o.label.toLowerCase().includes(q)) : options + + if (options.length === 0) { + return
{emptyLabel}
+ } + + return ( +
+ + + {open && ( +
+
+ + setQuery(e.target.value)} + placeholder={searchPlaceholder || t('common.search')} + aria-label={searchPlaceholder || t('common.search')} + style={{ + width: '100%', + boxSizing: 'border-box', + fontSize: 13, + padding: '6px 8px 6px 28px', + borderRadius: 6, + border: '1px solid var(--border)', + background: 'var(--bg-input)', + }} + /> +
+
+ {filtered.length === 0 ? ( +
+ {t('common.noResults')} +
+ ) : ( + filtered.map((o) => ( + + )) + )} +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/metadata/MultiSelectDropdown.test.jsx b/frontend/src/components/metadata/MultiSelectDropdown.test.jsx new file mode 100644 index 0000000..328ffc1 --- /dev/null +++ b/frontend/src/components/metadata/MultiSelectDropdown.test.jsx @@ -0,0 +1,96 @@ +import { describe, it, expect, vi } from 'vitest' +import { useState } from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import MultiSelectDropdown from './MultiSelectDropdown' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (k, o) => + ({ + 'sortFilter.multiAny': 'Any', + 'sortFilter.multiSelected': `${o?.count} selected`, + 'sortFilter.clear': 'Clear', + 'common.search': 'Search', + 'common.noResults': 'No results', + })[k] || k, + }), +})) + +const options = [ + { value: 'osr', label: 'osr' }, + { value: 'fantasy', label: 'fantasy' }, + { value: 'grim', label: 'grim' }, +] + +function Harness({ initial = [] }) { + const [selected, setSelected] = useState(initial) + return ( +
+ + {JSON.stringify(selected)} +
+ ) +} + +describe('MultiSelectDropdown', () => { + it('shows "Any" when nothing is selected', () => { + render() + expect(screen.getByRole('button', { name: 'Tags' })).toHaveTextContent('Any') + }) + + it('opens and toggles a value', async () => { + render() + await userEvent.click(screen.getByRole('button', { name: 'Tags' })) + await userEvent.click(screen.getByRole('checkbox', { name: 'osr' })) + expect(screen.getByTestId('sel').textContent).toBe('["osr"]') + }) + + it('filters options by the search box', async () => { + render() + await userEvent.click(screen.getByRole('button', { name: 'Tags' })) + await userEvent.type(screen.getByLabelText('Search'), 'gr') + expect(screen.getByRole('checkbox', { name: 'grim' })).toBeInTheDocument() + expect(screen.queryByRole('checkbox', { name: 'osr' })).not.toBeInTheDocument() + }) + + it('shows a selected count and clears all', async () => { + render() + const trigger = screen.getByRole('button', { name: 'Tags' }) + expect(trigger).toHaveTextContent('2 selected') + // The clear (X) affordance inside the trigger resets the selection. + await userEvent.click(screen.getByLabelText('Clear')) + expect(screen.getByTestId('sel').textContent).toBe('[]') + }) + + it('renders an empty label when there are no options', () => { + render( + {}} + emptyLabel="No tags" + /> + ) + expect(screen.getByText('No tags')).toBeInTheDocument() + }) + + it('closes when clicking outside', async () => { + render( +
+ + +
+ ) + await userEvent.click(screen.getByRole('button', { name: 'Tags' })) + expect(screen.getByRole('checkbox', { name: 'osr' })).toBeInTheDocument() + fireEvent.mouseDown(screen.getByText('outside')) + expect(screen.queryByRole('checkbox', { name: 'osr' })).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/metadata/SingleSelectCombobox.jsx b/frontend/src/components/metadata/SingleSelectCombobox.jsx new file mode 100644 index 0000000..d1125fc --- /dev/null +++ b/frontend/src/components/metadata/SingleSelectCombobox.jsx @@ -0,0 +1,202 @@ +import { useState, useRef, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { LuX } from 'react-icons/lu' +import api from '../../api' + +/** + * Single-value combobox with a filterable flat dropdown and an explicit + * "Create «text»" row for new values — a flat cousin of GenrePicker. The chosen + * value fills the input; typing filters the options and (when the text matches + * nothing) offers a create row. Creating best-effort persists the value to the + * lookup table at `createEndpoint` (admin only) and calls `onCreate` to refresh. + * + * Props: + * id – input id (also used for the clear button aria) + * value – current string value + * onChange – (nextValue) => void + * options – array of known string values + * placeholder – input placeholder + * createEndpoint – REST path to POST {name} for a new value (optional) + * onCreate – called after a successful create, to reload the lookup list + * createLabel – (name) => string for the create row (defaults to `Create "name"`) + */ +export default function SingleSelectCombobox({ + id, + value, + onChange, + options = [], + placeholder = '', + createEndpoint, + onCreate, + createLabel, +}) { + const { t } = useTranslation() + // `query` mirrors the input; it starts from the committed value and is kept in + // sync when the value changes from outside. + const [query, setQuery] = useState(value || '') + const [open, setOpen] = useState(false) + const [activeIdx, setActiveIdx] = useState(0) + const wrapRef = useRef(null) + + useEffect(() => { + setQuery(value || '') + }, [value]) + + useEffect(() => { + const onDoc = (e) => { + if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false) + } + document.addEventListener('mousedown', onDoc) + return () => document.removeEventListener('mousedown', onDoc) + }, []) + + const q = query.trim().toLowerCase() + const matches = options.filter((o) => o.toLowerCase().includes(q)) + const exact = options.some((o) => o.toLowerCase() === q) + const canCreate = q.length > 0 && !exact + + const rows = [ + ...matches.map((name) => ({ type: 'option', name })), + ...(canCreate ? [{ type: 'create', name: query.trim() }] : []), + ] + + const commit = (name) => { + const v = name.trim() + onChange(v) + setQuery(v) + setOpen(false) + } + + const create = (name) => { + const v = name.trim() + if (!v) return + commit(v) + // Best-effort persist so the value appears in the list next time. Ignored if + // not permitted (non-admin) or already exists. + if (createEndpoint && !options.some((o) => o.toLowerCase() === v.toLowerCase())) { + api + .post(createEndpoint, { name: v }) + .then((created) => onCreate && onCreate(created)) + .catch(() => {}) + } + } + + const choose = (row) => { + if (!row) return + if (row.type === 'create') create(row.name) + else commit(row.name) + } + + const onKeyDown = (e) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setOpen(true) + setActiveIdx((i) => Math.min(i + 1, rows.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIdx((i) => Math.max(i - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + if (rows.length) choose(rows[activeIdx] || rows[0]) + else commit(query) + } else if (e.key === 'Escape') { + setOpen(false) + } + } + + return ( +
+
+ { + setQuery(e.target.value) + // Typing edits the value directly; picking/creating commits a final one. + onChange(e.target.value) + setOpen(true) + setActiveIdx(0) + }} + onFocus={() => setOpen(true)} + onKeyDown={onKeyDown} + placeholder={placeholder} + style={{ width: '100%', paddingRight: query ? 26 : undefined }} + /> + {query && ( + + )} +
+ {open && rows.length > 0 && ( +
+ {rows.map((row, i) => ( + + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/metadata/SingleSelectCombobox.test.jsx b/frontend/src/components/metadata/SingleSelectCombobox.test.jsx new file mode 100644 index 0000000..e5b7f42 --- /dev/null +++ b/frontend/src/components/metadata/SingleSelectCombobox.test.jsx @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useState } from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import SingleSelectCombobox from './SingleSelectCombobox' +import api from '../../api' + +vi.mock('../../api', () => ({ default: { post: vi.fn(() => Promise.resolve({ id: 'x' })) } })) +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (k, o) => (k === 'common.clear' ? 'Clear' : `Create "${o?.name}"`), + }), +})) + +beforeEach(() => vi.clearAllMocks()) + +function Harness({ options = ['Dungeons & Dragons', 'Cyberpunk'], initial = '', ...rest }) { + const [value, setValue] = useState(initial) + return ( +
+ + {value} +
+ ) +} + +describe('SingleSelectCombobox', () => { + it('lists options on focus and selects one by click', async () => { + render() + await userEvent.click(screen.getByRole('combobox')) + await userEvent.click(screen.getByRole('option', { name: 'Cyberpunk' })) + expect(screen.getByTestId('val').textContent).toBe('Cyberpunk') + }) + + it('filters options as you type', () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'cyber' } }) + expect(screen.getByRole('option', { name: 'Cyberpunk' })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: 'Dungeons & Dragons' })).not.toBeInTheDocument() + }) + + it('offers a create row for unknown text and commits it', async () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'Pathfinder' } }) + await userEvent.click(screen.getByRole('option', { name: /Create "Pathfinder"/ })) + expect(screen.getByTestId('val').textContent).toBe('Pathfinder') + }) + + it('persists a created value to the endpoint and calls onCreate', async () => { + const onCreate = vi.fn() + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'Pathfinder' } }) + await userEvent.click(screen.getByRole('option', { name: /Create "Pathfinder"/ })) + await waitFor(() => + expect(api.post).toHaveBeenCalledWith('/parent-systems', { name: 'Pathfinder' }) + ) + await waitFor(() => expect(onCreate).toHaveBeenCalled()) + }) + + it('does not offer create for an exact existing match', () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'Cyberpunk' } }) + expect(screen.queryByRole('option', { name: /Create/ })).not.toBeInTheDocument() + }) + + it('clears the value via the clear button', async () => { + render() + await userEvent.click(screen.getByRole('button', { name: 'Clear' })) + expect(screen.getByTestId('val').textContent).toBe('') + }) + + it('navigates with arrows and commits on Enter', () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + // Two options; ArrowDown moves to the second, Enter commits it. + fireEvent.keyDown(input, { key: 'ArrowDown' }) + fireEvent.keyDown(input, { key: 'ArrowUp' }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(screen.getByTestId('val').textContent).toBe('Dungeons & Dragons') + }) + + it('commits the raw query on Enter when no rows match', () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: ' ' } }) // whitespace → no create row + fireEvent.keyDown(input, { key: 'Enter' }) + expect(screen.getByTestId('val').textContent).toBe('') + }) + + it('closes the dropdown on Escape', async () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + expect(screen.getByRole('option', { name: 'Cyberpunk' })).toBeInTheDocument() + fireEvent.keyDown(input, { key: 'Escape' }) + await waitFor(() => + expect(screen.queryByRole('option', { name: 'Cyberpunk' })).not.toBeInTheDocument() + ) + }) + + it('does not persist when creating without a createEndpoint', async () => { + render() + const input = screen.getByRole('combobox') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'Pathfinder' } }) + await userEvent.click(screen.getByRole('option', { name: /Create "Pathfinder"/ })) + expect(api.post).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/metadata/TagPicker.jsx b/frontend/src/components/metadata/TagPicker.jsx new file mode 100644 index 0000000..3dfc132 --- /dev/null +++ b/frontend/src/components/metadata/TagPicker.jsx @@ -0,0 +1,329 @@ +import { useState, useRef, useEffect, useMemo, useCallback } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { LuX, LuHeart } from 'react-icons/lu' +import { tags as tagsApi } from '../../api' + +const GROUP_LIMIT = 10 +const DEBOUNCE_MS = 200 + +/** + * Single-input tag combobox shared by every tag-editing surface (issue #235). + * Selected tags render as removable chips above the input (like GenrePicker). + * + * Typing (debounced) opens a grouped dropdown: + * 0. Create "" — when there's no exact match + * 1. Favorite tags + * 2. Tags in this field's category + shared tags (limit 10) + * 3. All remaining tags (limit 10) + * An empty input shows only favorites (there can be many tags). `resourceType` + * is the field's category (system|book|map|token|audio); a tag "belongs" to + * group 2 when its category equals that or is `shared`. + * + * Values are stored as the tag's display casing (or the entered text for a new + * tag); matching/dedup is case-insensitive. `value` is the selected string[]; + * `onChange(next)` receives the updated list. + */ +export default function TagPicker({ value = [], onChange, resourceType = null, placeholder }) { + const { t } = useTranslation() + const [allTags, setAllTags] = useState([]) // [{internal, display, category, is_favorite}] + const [query, setQuery] = useState('') + const [debouncedQ, setDebouncedQ] = useState('') + const [open, setOpen] = useState(false) + const [activeIdx, setActiveIdx] = useState(0) + const [menuRect, setMenuRect] = useState(null) // {left, top, width} in viewport coords + const wrapRef = useRef(null) + const inputRef = useRef(null) + + // Load the whole tag catalog once (unscoped, so every tag's category is known + // and group 3 can list tags from other categories). + useEffect(() => { + let cancelled = false + tagsApi + .list() + .then((r) => !cancelled && setAllTags(r.tags || [])) + .catch(() => !cancelled && setAllTags([])) + return () => { + cancelled = true + } + }, []) + + // Debounce the query so results don't churn on every keystroke. + useEffect(() => { + const id = setTimeout(() => setDebouncedQ(query), DEBOUNCE_MS) + return () => clearTimeout(id) + }, [query]) + + const selectedKeys = useMemo(() => new Set(value.map((v) => v.trim().toLowerCase())), [value]) + const has = (internal) => selectedKeys.has(internal) + + // The dropdown renders in a portal (position: fixed) so an ancestor with + // overflow:hidden or a tight width can't clip it. Track the input's rect so the + // menu sits flush under it, and keep it fresh while open on scroll/resize. + const measure = useCallback(() => { + const el = inputRef.current + if (!el) return + const r = el.getBoundingClientRect() + setMenuRect({ left: r.left, top: r.bottom, width: r.width }) + }, []) + + useEffect(() => { + if (!open) return + measure() + window.addEventListener('scroll', measure, true) + window.addEventListener('resize', measure) + return () => { + window.removeEventListener('scroll', measure, true) + window.removeEventListener('resize', measure) + } + // Re-measure when the chip row above the input grows/shrinks (moves the input). + }, [open, measure, value.length]) + + useEffect(() => { + const onDoc = (e) => { + // The menu is portalled outside wrapRef, so ignore clicks inside it. + if (e.target.closest?.('[data-tagpicker-menu]')) return + if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false) + } + document.addEventListener('mousedown', onDoc) + return () => document.removeEventListener('mousedown', onDoc) + }, []) + + const q = debouncedQ.trim().toLowerCase() + + // Build the grouped, flattened option list (with group headers) plus a parallel + // list of just the selectable rows for keyboard navigation. + const { flat, options } = useMemo(() => { + const avail = allTags.filter((tg) => !has(tg.internal)) + const matches = (tg) => !q || tg.internal.includes(q) || tg.display.toLowerCase().includes(q) + const byPrefixThenName = (a, b) => { + const ap = a.internal.startsWith(q) || a.display.toLowerCase().startsWith(q) + const bp = b.internal.startsWith(q) || b.display.toLowerCase().startsWith(q) + return bp - ap || a.display.localeCompare(b.display) + } + + let candidates + if (!q) { + // Empty input: only favorites (avoid dumping the whole catalog). + candidates = avail.filter((tg) => tg.is_favorite) + } else { + candidates = avail.filter(matches) + } + + const inThisCategory = (tg) => + tg.category === 'shared' || (resourceType && tg.category === resourceType) + + const favorites = candidates.filter((tg) => tg.is_favorite).sort(byPrefixThenName) + const rest = candidates.filter((tg) => !tg.is_favorite) + const category = rest.filter(inThisCategory).sort(byPrefixThenName).slice(0, GROUP_LIMIT) + const inCategoryKeys = new Set(category.map((tg) => tg.internal)) + const remaining = rest + .filter((tg) => !inCategoryKeys.has(tg.internal)) + .sort(byPrefixThenName) + .slice(0, GROUP_LIMIT) + + const exact = allTags.some((tg) => tg.internal === q) + const canCreate = q.length > 0 && !exact && !has(q) + + const groups = [ + canCreate && { header: null, rows: [{ type: 'create', display: query.trim() }] }, + favorites.length && { header: t('tags.groupFavorites'), rows: favorites }, + category.length && { header: t('tags.groupCategory'), rows: category }, + remaining.length && { header: t('tags.groupOther'), rows: remaining }, + ].filter(Boolean) + + // Flatten to render (headers + rows) and to a selectable-only list for nav. + const flatList = [] + const selectable = [] + for (const g of groups) { + if (g.header) flatList.push({ kind: 'header', label: g.header }) + for (const row of g.rows) { + const idx = selectable.length + flatList.push({ kind: 'option', row, idx }) + selectable.push(row) + } + } + return { flat: flatList, options: selectable } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allTags, q, selectedKeys, query, resourceType]) + + const addTag = (display) => { + const v = display.trim() + if (v && !has(v.toLowerCase())) onChange([...value, v]) + setQuery('') + setDebouncedQ('') + setActiveIdx(0) + setOpen(true) + } + + const choose = (row) => { + if (!row) return + addTag(row.display) + } + + const onKeyDown = (e) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setOpen(true) + setActiveIdx((i) => Math.min(i + 1, options.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIdx((i) => Math.max(i - 1, 0)) + } else if (e.key === 'Enter' || e.key === ',') { + e.preventDefault() + if (options.length) choose(options[activeIdx] || options[0]) + else if (query.trim()) addTag(query.trim()) + } else if (e.key === 'Escape') { + setOpen(false) + } + // Backspace intentionally does nothing special: it just edits the input + // text. Removing a selected tag is done via its chip's ✕ button, so an + // accidental Backspace can't silently delete tags. + } + + return ( +
+ {/* Selected chips (above the input, GenrePicker-style) */} +
+ {value.length === 0 && ( + {t('tags.noTags')} + )} + {value.map((tag) => ( + + {tag} + + + ))} +
+ { + setQuery(e.target.value) + setOpen(true) + setActiveIdx(0) + }} + onFocus={() => setOpen(true)} + onKeyDown={onKeyDown} + placeholder={placeholder || t('tags.addTagPlaceholder')} + style={{ width: '100%', boxSizing: 'border-box' }} + /> + {open && + flat.length > 0 && + menuRect && + createPortal( +
+ {flat.map((entry, i) => + entry.kind === 'header' ? ( +
+ {entry.label} +
+ ) : ( + + ) + )} +
, + document.body + )} +
+ ) +} diff --git a/frontend/src/components/metadata/TagPicker.test.jsx b/frontend/src/components/metadata/TagPicker.test.jsx new file mode 100644 index 0000000..9427f3c --- /dev/null +++ b/frontend/src/components/metadata/TagPicker.test.jsx @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useState } from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import TagPicker from './TagPicker' + +const mockList = vi.fn() +vi.mock('../../api', () => ({ + tags: { list: (...a) => mockList(...a) }, +})) + +beforeEach(() => { + vi.clearAllMocks() + mockList.mockResolvedValue({ + tags: [ + { internal: 'dinosaur', display: 'Dinosaur', category: 'map', is_favorite: true }, + { internal: 'building', display: 'Building', category: 'map', is_favorite: false }, + { internal: 'dungeon', display: 'Dungeon', category: 'map', is_favorite: false }, + { internal: 'lore', display: 'Lore', category: 'book', is_favorite: false }, + { internal: 'strahd', display: 'Strahd', category: 'shared', is_favorite: false }, + ], + }) +}) + +function Harness({ initial = [], resourceType = null }) { + const [value, setValue] = useState(initial) + return +} + +describe('TagPicker', () => { + it('renders selected tags as chips', () => { + render() + expect(screen.getByText('forest')).toBeInTheDocument() + expect(screen.getByText('cave')).toBeInTheDocument() + }) + + it('fetches the full (unscoped) catalog so every tag category is known', async () => { + render() + await waitFor(() => expect(mockList).toHaveBeenCalledWith()) + }) + + it('shows only favorited tags on focus with an empty input', async () => { + render() + await waitFor(() => expect(mockList).toHaveBeenCalled()) + fireEvent.focus(screen.getByRole('combobox')) + // Dinosaur is the only favorite → shown; non-favorites hidden until typing. + expect(await screen.findByRole('option', { name: /Dinosaur/ })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: /Building/ })).not.toBeInTheDocument() + }) + + it('ranks a favorite prefix match first', async () => { + render() + await waitFor(() => expect(mockList).toHaveBeenCalled()) + // "din" prefix-matches the favorite Dinosaur; it should be first (debounced). + await userEvent.type(screen.getByRole('combobox'), 'din') + await screen.findByRole('option', { name: /Dinosaur/ }) + const options = screen.getAllByRole('option').map((o) => o.textContent) + expect(options[0]).toContain('Dinosaur') + }) + + it('groups this category + shared separately from other-category tags', async () => { + render() + await waitFor(() => expect(mockList).toHaveBeenCalled()) + // "d" matches Strahd (shared) → category group; Dinosaur (fav); + // Building/Dungeon (map) → other-category group. + await userEvent.type(screen.getByRole('combobox'), 'd') + expect(await screen.findByText(/this category & shared/i)).toBeInTheDocument() + expect(screen.getByText(/all other tags/i)).toBeInTheDocument() + // Shared tag is offered in the category group. + expect(screen.getByRole('option', { name: /Strahd/ })).toBeInTheDocument() + // Map tags fall into the other-category group. + expect(screen.getByRole('option', { name: /Building/ })).toBeInTheDocument() + expect(screen.getByRole('option', { name: /Dungeon/ })).toBeInTheDocument() + }) + + it('adds a chosen existing tag with its display casing', async () => { + render() + await waitFor(() => expect(mockList).toHaveBeenCalled()) + await userEvent.type(screen.getByRole('combobox'), 'dun') + fireEvent.mouseDown(await screen.findByRole('option', { name: /Dungeon/ })) + expect(screen.getByText('Dungeon')).toBeInTheDocument() + }) + + it('offers a create row and adds a new tag on Enter', async () => { + render() + await waitFor(() => expect(mockList).toHaveBeenCalled()) + await userEvent.type(screen.getByRole('combobox'), 'Brand New') + // Wait for the debounced create row, then commit. + await screen.findByRole('option', { name: /Create/i }) + await userEvent.type(screen.getByRole('combobox'), '{Enter}') + expect(screen.getByText('Brand New')).toBeInTheDocument() + }) + + it('does not remove tags on Backspace (tags are removed via their ✕ button)', async () => { + render() + const input = screen.getByRole('combobox') + input.focus() + fireEvent.keyDown(input, { key: 'Backspace' }) + // Both chips remain — Backspace only edits the input text. + expect(screen.getByText('a')).toBeInTheDocument() + expect(screen.getByText('b')).toBeInTheDocument() + }) + + it('does not suggest an already-selected tag', async () => { + render() + await waitFor(() => expect(mockList).toHaveBeenCalled()) + await userEvent.type(screen.getByRole('combobox'), 'dun') + // Give the debounce time to settle, then confirm Dungeon is not offered. + await waitFor(() => + expect(screen.queryByRole('option', { name: /Dungeon/ })).not.toBeInTheDocument() + ) + }) +}) diff --git a/frontend/src/components/metadata/diceMaterials.js b/frontend/src/components/metadata/diceMaterials.js new file mode 100644 index 0000000..eea4f39 --- /dev/null +++ b/frontend/src/components/metadata/diceMaterials.js @@ -0,0 +1,91 @@ +// Default dice / materials options for a game system, grouped for the picker. +// Groups are display-only (unselectable); their items are selectable values. +// User-entered values that aren't in any default group are shown under "Custom". + +export const DICE_MATERIAL_GROUPS = [ + { + key: 'dice', + label: 'Dice', + items: ['D4', 'D6', 'D8', 'D10', 'D12', 'D20', 'D100', 'Custom (System specific)'], + }, + { + key: 'cards', + label: 'Cards', + items: ['Playing Cards', 'Tarot Cards', 'Custom Deck'], + }, + { + key: 'other', + label: 'Other', + items: ['Tumbling Tower (Jenga Tower)', 'Candles', 'Poker Chips', 'Timers', 'Phone'], + }, +] + +// Flat set of all default item values (lowercased) for "is this custom?" checks. +const DEFAULT_VALUES = new Set( + DICE_MATERIAL_GROUPS.flatMap((g) => g.items.map((i) => i.toLowerCase())) +) + +export function isDefaultDiceMaterial(value) { + return DEFAULT_VALUES.has(String(value).trim().toLowerCase()) +} + +// Canonical group ordering for managed items; unknown groups come after these. +const GROUP_ORDER = ['Dice', 'Cards', 'Other', 'Custom'] + +/** + * Convert the managed dice/materials lookup rows ([{name, group}]) into the same + * grouped shape as DICE_MATERIAL_GROUPS. Groups are ordered Dice → Cards → Other + * → Custom, then any unexpected groups alphabetically. + */ +export function groupsFromManaged(items = []) { + const byGroup = new Map() + for (const item of items) { + const group = (item.group || 'Custom').trim() || 'Custom' + if (!byGroup.has(group)) byGroup.set(group, []) + byGroup.get(group).push(item.name) + } + const groupNames = [...byGroup.keys()].sort((a, b) => { + const ai = GROUP_ORDER.indexOf(a) + const bi = GROUP_ORDER.indexOf(b) + if (ai !== -1 || bi !== -1) return (ai === -1 ? Infinity : ai) - (bi === -1 ? Infinity : bi) + return a.localeCompare(b) + }) + return groupNames.map((name) => ({ + key: name.toLowerCase(), + label: name, + items: byGroup.get(name), + })) +} + +/** + * Build the flat, group-ordered option list for the picker. Each entry is + * either { type: 'group', label } (unselectable header) or + * { type: 'item', value, groupKey }. Selected values not present in any group + * are appended under a trailing "Custom" group so they can be seen/removed. + * + * @param selected currently selected values (to surface custom ones) + * @param customGroupLabel localized label for the trailing custom group + * @param groups group definitions to build from; defaults to the built-in list. + * Pass `groupsFromManaged(lookupRows)` to source from the managed table. + */ +export function buildDiceMaterialRows( + selected = [], + customGroupLabel = 'Custom', + groups = DICE_MATERIAL_GROUPS +) { + const rows = [] + const known = new Set() + for (const g of groups) { + rows.push({ type: 'group', label: g.label, key: g.key }) + for (const item of g.items) { + rows.push({ type: 'item', value: item, groupKey: g.key }) + known.add(String(item).trim().toLowerCase()) + } + } + const customs = selected.filter((v) => !known.has(String(v).trim().toLowerCase())) + if (customs.length) { + rows.push({ type: 'group', label: customGroupLabel, key: '__custom__' }) + for (const value of customs) rows.push({ type: 'item', value, groupKey: '__custom__' }) + } + return rows +} diff --git a/frontend/src/components/metadata/diceMaterials.test.js b/frontend/src/components/metadata/diceMaterials.test.js new file mode 100644 index 0000000..19ca7f9 --- /dev/null +++ b/frontend/src/components/metadata/diceMaterials.test.js @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest' +import { + DICE_MATERIAL_GROUPS, + isDefaultDiceMaterial, + buildDiceMaterialRows, + groupsFromManaged, +} from './diceMaterials' + +describe('diceMaterials', () => { + it('defines the Dice/Cards/Other default groups', () => { + const labels = DICE_MATERIAL_GROUPS.map((g) => g.label) + expect(labels).toEqual(['Dice', 'Cards', 'Other']) + const dice = DICE_MATERIAL_GROUPS.find((g) => g.key === 'dice') + expect(dice.items).toContain('D20') + expect(dice.items).toContain('Custom (System specific)') + }) + + it('recognizes default values case-insensitively', () => { + expect(isDefaultDiceMaterial('d20')).toBe(true) + expect(isDefaultDiceMaterial('Tarot Cards')).toBe(true) + expect(isDefaultDiceMaterial('Homebrew Widget')).toBe(false) + }) + + it('builds group headers followed by their items', () => { + const rows = buildDiceMaterialRows([]) + expect(rows[0]).toMatchObject({ type: 'group', label: 'Dice' }) + expect(rows[1]).toMatchObject({ type: 'item', value: 'D4' }) + }) + + it('appends selected custom values under a Custom group', () => { + const rows = buildDiceMaterialRows(['D6', 'Homebrew Widget'], 'Custom') + const customHeader = rows.find((r) => r.type === 'group' && r.label === 'Custom') + expect(customHeader).toBeTruthy() + const customItem = rows.find((r) => r.type === 'item' && r.value === 'Homebrew Widget') + expect(customItem.groupKey).toBe('__custom__') + // A default value like D6 is NOT duplicated into the custom group. + expect(rows.filter((r) => r.type === 'item' && r.value === 'D6')).toHaveLength(1) + }) + + describe('groupsFromManaged', () => { + it('groups managed rows and orders Dice → Cards → Other → Custom', () => { + const groups = groupsFromManaged([ + { name: 'Poker Chips', group: 'Other' }, + { name: 'D20', group: 'Dice' }, + { name: 'Tarot Cards', group: 'Cards' }, + { name: 'My Widget', group: 'Custom' }, + ]) + expect(groups.map((g) => g.label)).toEqual(['Dice', 'Cards', 'Other', 'Custom']) + expect(groups[0].items).toEqual(['D20']) + }) + + it('places unknown groups after the canonical ones, alphabetically', () => { + const groups = groupsFromManaged([ + { name: 'Zeta', group: 'Zeta' }, + { name: 'Alpha', group: 'Alpha' }, + { name: 'D6', group: 'Dice' }, + ]) + expect(groups.map((g) => g.label)).toEqual(['Dice', 'Alpha', 'Zeta']) + }) + + it('defaults a missing group to Custom', () => { + const groups = groupsFromManaged([{ name: 'Loose' }]) + expect(groups[0].label).toBe('Custom') + expect(groups[0].items).toEqual(['Loose']) + }) + }) + + it('builds rows from a supplied managed group list', () => { + const managed = groupsFromManaged([ + { name: 'D20', group: 'Dice' }, + { name: 'My Widget', group: 'Custom' }, + ]) + const rows = buildDiceMaterialRows([], 'Custom', managed) + expect(rows.find((r) => r.type === 'item' && r.value === 'D20')).toBeTruthy() + expect(rows.find((r) => r.type === 'item' && r.value === 'My Widget')).toBeTruthy() + // A value not in the supplied groups is surfaced under the trailing Custom group. + const rows2 = buildDiceMaterialRows(['Unlisted'], 'Custom', managed) + const unlisted = rows2.find((r) => r.type === 'item' && r.value === 'Unlisted') + expect(unlisted.groupKey).toBe('__custom__') + }) +}) diff --git a/frontend/src/components/metadata/metadataUtils.js b/frontend/src/components/metadata/metadataUtils.js new file mode 100644 index 0000000..71fa3c8 --- /dev/null +++ b/frontend/src/components/metadata/metadataUtils.js @@ -0,0 +1,37 @@ +// Shared helpers for the metadata editor components (issue #202). + +/** Drop links with neither a label nor a URL (used before saving). */ +export function cleanLinks(links) { + return (links || []).filter((l) => (l.label || '').trim() || (l.url || '').trim()) +} + +/** Ensure a link list has at least one (blank) row for editing. */ +export function linksForEditing(links) { + return links && links.length ? links : [{ label: '', url: '' }] +} + +/** + * Build a tiered, ordered list of genres from the flat lookup rows. Each entry + * is { id, name, depth } with children following their parent, so a dropdown + * can indent by depth. + */ +export function buildGenreTree(genres) { + const byParent = new Map() + for (const g of genres) { + const key = g.parent_id || '__root__' + if (!byParent.has(key)) byParent.set(key, []) + byParent.get(key).push(g) + } + for (const list of byParent.values()) { + list.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0) || a.name.localeCompare(b.name)) + } + const out = [] + const walk = (parentKey, depth) => { + for (const g of byParent.get(parentKey) || []) { + out.push({ id: g.id, name: g.name, depth }) + walk(g.id, depth + 1) + } + } + walk('__root__', 0) + return out +} diff --git a/frontend/src/components/metadata/metadataUtils.test.js b/frontend/src/components/metadata/metadataUtils.test.js new file mode 100644 index 0000000..7548aa7 --- /dev/null +++ b/frontend/src/components/metadata/metadataUtils.test.js @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest' +import { cleanLinks, linksForEditing, buildGenreTree } from './metadataUtils' + +describe('cleanLinks', () => { + it('drops rows with neither label nor url', () => { + expect( + cleanLinks([ + { label: '', url: '' }, + { label: 'X', url: '' }, + { label: '', url: 'http://y' }, + ]) + ).toEqual([ + { label: 'X', url: '' }, + { label: '', url: 'http://y' }, + ]) + }) + + it('handles null input', () => { + expect(cleanLinks(null)).toEqual([]) + }) +}) + +describe('linksForEditing', () => { + it('returns a blank row when empty', () => { + expect(linksForEditing([])).toEqual([{ label: '', url: '' }]) + expect(linksForEditing(null)).toEqual([{ label: '', url: '' }]) + }) + + it('passes through existing links', () => { + const links = [{ label: 'a', url: 'b' }] + expect(linksForEditing(links)).toBe(links) + }) +}) + +describe('buildGenreTree', () => { + const genres = [ + { id: 'sci', name: 'Science Fiction', parent_id: null, sort_order: 2 }, + { id: 'cyber', name: 'Cyberpunk', parent_id: 'sci', sort_order: 1 }, + { id: 'fan', name: 'Fantasy', parent_id: null, sort_order: 1 }, + ] + + it('orders parents by sort_order with children nested below', () => { + const tree = buildGenreTree(genres) + const names = tree.map((g) => g.name) + expect(names).toEqual(['Fantasy', 'Science Fiction', 'Cyberpunk']) + }) + + it('assigns depth by nesting level', () => { + const tree = buildGenreTree(genres) + const cyber = tree.find((g) => g.name === 'Cyberpunk') + expect(cyber.depth).toBe(1) + expect(tree.find((g) => g.name === 'Fantasy').depth).toBe(0) + }) + + it('handles an empty list', () => { + expect(buildGenreTree([])).toEqual([]) + }) +}) diff --git a/frontend/src/components/metadata/useLookups.js b/frontend/src/components/metadata/useLookups.js new file mode 100644 index 0000000..dead41f --- /dev/null +++ b/frontend/src/components/metadata/useLookups.js @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react' +import api from '../../api' + +/** + * Load the metadata lookup lists once for editor dropdowns: genres (tree), + * system families, parent systems, licenses, and dice/materials. Returns the + * lists plus `reload` to re-fetch after a custom value is created. Failures + * degrade to empty lists (custom free-text entry still works). + */ +export default function useLookups() { + const [genres, setGenres] = useState([]) + const [families, setFamilies] = useState([]) + const [parentSystems, setParentSystems] = useState([]) + const [licenses, setLicenses] = useState([]) + const [diceMaterials, setDiceMaterials] = useState([]) + + const load = () => { + api + .get('/genres') + .then((r) => setGenres(r.genres || [])) + .catch(() => setGenres([])) + api + .get('/system-families') + .then((r) => setFamilies(r.families || [])) + .catch(() => setFamilies([])) + api + .get('/parent-systems') + .then((r) => setParentSystems(r.parent_systems || [])) + .catch(() => setParentSystems([])) + api + .get('/licenses') + .then((r) => setLicenses(r.licenses || [])) + .catch(() => setLicenses([])) + api + .get('/dice-materials') + .then((r) => setDiceMaterials(r.dice_materials || [])) + .catch(() => setDiceMaterials([])) + } + + useEffect(() => { + load() + }, []) + + return { genres, families, parentSystems, licenses, diceMaterials, reload: load } +} diff --git a/frontend/src/components/metadata/useLookups.test.js b/frontend/src/components/metadata/useLookups.test.js new file mode 100644 index 0000000..12a897a --- /dev/null +++ b/frontend/src/components/metadata/useLookups.test.js @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, waitFor, act } from '@testing-library/react' +import useLookups from './useLookups' +import api from '../../api' + +vi.mock('../../api', () => ({ default: { get: vi.fn() } })) + +beforeEach(() => { + vi.clearAllMocks() + api.get.mockImplementation((path) => { + if (path.includes('genres')) return Promise.resolve({ genres: [{ id: 'g' }] }) + if (path.includes('system-families')) return Promise.resolve({ families: [{ id: 'f' }] }) + if (path.includes('parent-systems')) return Promise.resolve({ parent_systems: [{ id: 'p' }] }) + if (path.includes('licenses')) return Promise.resolve({ licenses: [{ id: 'l' }] }) + if (path.includes('dice-materials')) return Promise.resolve({ dice_materials: [{ id: 'd' }] }) + return Promise.resolve({}) + }) +}) + +// Number of lookup endpoints fetched on each load. +const ENDPOINT_COUNT = 5 + +describe('useLookups', () => { + it('loads every lookup list on mount', async () => { + const { result } = renderHook(() => useLookups()) + await waitFor(() => expect(result.current.genres).toHaveLength(1)) + expect(result.current.families).toHaveLength(1) + expect(result.current.parentSystems).toHaveLength(1) + expect(result.current.licenses).toHaveLength(1) + expect(result.current.diceMaterials).toHaveLength(1) + }) + + it('degrades to empty lists on failure', async () => { + api.get.mockRejectedValue(new Error('boom')) + const { result } = renderHook(() => useLookups()) + await waitFor(() => expect(api.get).toHaveBeenCalled()) + expect(result.current.genres).toEqual([]) + expect(result.current.families).toEqual([]) + expect(result.current.parentSystems).toEqual([]) + expect(result.current.licenses).toEqual([]) + expect(result.current.diceMaterials).toEqual([]) + }) + + it('reload re-fetches every list', async () => { + const { result } = renderHook(() => useLookups()) + await waitFor(() => expect(result.current.genres).toHaveLength(1)) + api.get.mockClear() + act(() => result.current.reload()) + await waitFor(() => expect(api.get).toHaveBeenCalledTimes(ENDPOINT_COUNT)) + }) +}) diff --git a/frontend/src/components/reader/BookmarkDialog.test.jsx b/frontend/src/components/reader/BookmarkDialog.test.jsx new file mode 100644 index 0000000..e1f6f1e --- /dev/null +++ b/frontend/src/components/reader/BookmarkDialog.test.jsx @@ -0,0 +1,77 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import BookmarkDialog from './BookmarkDialog' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (k, o) => + ({ + 'bookmark.bookmarkSelection': 'Bookmark selection', + 'bookmark.bookmarkPage': `Bookmark page ${o?.page}`, + 'bookmark.notes': 'Notes', + 'bookmark.notesPlaceholder': 'Notes…', + 'bookmark.cancel': 'Cancel', + 'bookmark.save': 'Save', + })[k] || k, + }), +})) + +function baseProps(over = {}) { + return { + pendingBookmark: { page: 5 }, + pendingLabel: '', + pendingNotes: '', + onLabelChange: vi.fn(), + onNotesChange: vi.fn(), + onSave: vi.fn(), + onClose: vi.fn(), + ...over, + } +} + +describe('BookmarkDialog', () => { + it('shows the page title and the selected-text preview', () => { + render( + + ) + expect(screen.getByText('Bookmark selection')).toBeInTheDocument() + expect(screen.getByText('"a fireball"')).toBeInTheDocument() + }) + + it('shows a page-based title when no text is selected', () => { + render() + expect(screen.getByText('Bookmark page 5')).toBeInTheDocument() + }) + + it('propagates label and notes edits', async () => { + const onLabelChange = vi.fn() + const onNotesChange = vi.fn() + render() + await userEvent.type(screen.getByLabelText('bookmark.label'), 'x') + await userEvent.type(screen.getByLabelText('Notes'), 'y') + expect(onLabelChange).toHaveBeenCalled() + expect(onNotesChange).toHaveBeenCalled() + }) + + it('saves and closes via the buttons', async () => { + const onSave = vi.fn() + const onClose = vi.fn() + render() + await userEvent.click(screen.getByText('Save')) + expect(onSave).toHaveBeenCalled() + await userEvent.click(screen.getByText('Cancel')) + expect(onClose).toHaveBeenCalled() + }) + + it('closes on backdrop click and on Escape in a field', () => { + const onClose = vi.fn() + render() + fireEvent.keyDown(screen.getByLabelText('Notes'), { key: 'Escape' }) + expect(onClose).toHaveBeenCalledTimes(1) + fireEvent.click(screen.getByRole('dialog')) + expect(onClose).toHaveBeenCalledTimes(2) + }) +}) diff --git a/frontend/src/components/reader/SelectionPopup.test.jsx b/frontend/src/components/reader/SelectionPopup.test.jsx new file mode 100644 index 0000000..bf117ce --- /dev/null +++ b/frontend/src/components/reader/SelectionPopup.test.jsx @@ -0,0 +1,21 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import SelectionPopup from './SelectionPopup' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: () => 'Bookmark selection' }), +})) + +describe('SelectionPopup', () => { + it('renders the bookmark button and calls onBookmark with page + text', () => { + const onBookmark = vi.fn() + render( + + ) + fireEvent.mouseDown(screen.getByText('Bookmark selection')) + expect(onBookmark).toHaveBeenCalledWith(7, 'a spell') + }) +}) diff --git a/frontend/src/components/settings/CollapsibleSection.jsx b/frontend/src/components/settings/CollapsibleSection.jsx new file mode 100644 index 0000000..849026f --- /dev/null +++ b/frontend/src/components/settings/CollapsibleSection.jsx @@ -0,0 +1,71 @@ +import { useState } from 'react' +import { LuChevronDown, LuChevronRight } from 'react-icons/lu' + +/** + * Collapsible wrapper for a settings section: a clickable header (title + + * optional description) that toggles the body. Open by default; the open/closed + * state is remembered per-browser under `storageKey` when provided. + */ +export default function CollapsibleSection({ + title, + description, + storageKey, + defaultOpen = true, + children, +}) { + const [open, setOpen] = useState(() => { + if (!storageKey) return defaultOpen + const saved = localStorage.getItem(storageKey) + return saved === null ? defaultOpen : saved === '1' + }) + + const toggle = () => { + setOpen((prev) => { + const next = !prev + if (storageKey) localStorage.setItem(storageKey, next ? '1' : '0') + return next + }) + } + + return ( +
+ + {open && ( +
+ {description && ( +

+ {description} +

+ )} + {children} +
+ )} +
+ ) +} diff --git a/frontend/src/components/settings/CollapsibleSection.test.jsx b/frontend/src/components/settings/CollapsibleSection.test.jsx new file mode 100644 index 0000000..d9ca25a --- /dev/null +++ b/frontend/src/components/settings/CollapsibleSection.test.jsx @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import CollapsibleSection from './CollapsibleSection' + +describe('CollapsibleSection', () => { + beforeEach(() => localStorage.clear()) + + it('renders open by default with title, description, and children', () => { + render( + +
body
+
+ ) + expect(screen.getByText('Genres')).toBeInTheDocument() + expect(screen.getByText('Manage genres')).toBeInTheDocument() + expect(screen.getByText('body')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /genres/i })).toHaveAttribute('aria-expanded', 'true') + }) + + it('toggles the body when the header is clicked', async () => { + render( + +
body
+
+ ) + await userEvent.click(screen.getByRole('button', { name: /genres/i })) + expect(screen.queryByText('body')).not.toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: /genres/i })) + expect(screen.getByText('body')).toBeInTheDocument() + }) + + it('remembers collapsed state via storageKey', async () => { + const { unmount } = render( + +
body
+
+ ) + await userEvent.click(screen.getByRole('button', { name: /genres/i })) + expect(localStorage.getItem('k1')).toBe('0') + unmount() + + render( + +
body
+
+ ) + // Persisted collapsed → body hidden on remount. + expect(screen.queryByText('body')).not.toBeInTheDocument() + }) + + it('honors defaultOpen=false when no stored state', () => { + render( + +
body
+
+ ) + expect(screen.queryByText('body')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/settings/DeleteAccountSection.test.jsx b/frontend/src/components/settings/DeleteAccountSection.test.jsx new file mode 100644 index 0000000..96ec98d --- /dev/null +++ b/frontend/src/components/settings/DeleteAccountSection.test.jsx @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import DeleteAccountSection from './DeleteAccountSection' +import api from '../../api' + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k) => k }) })) +vi.mock('../Spinner', () => ({ default: () => })) +vi.mock('../../api', () => ({ default: { delete: vi.fn(() => Promise.resolve({})) } })) + +beforeEach(() => vi.clearAllMocks()) + +describe('DeleteAccountSection', () => { + it('disables the delete button for admins and shows the admin warning', () => { + render() + expect(screen.getByText('userSettings.deleteAccount.deleteButton')).toBeDisabled() + expect(screen.getByText('userSettings.deleteAccount.adminWarning')).toBeInTheDocument() + }) + + it('confirms then deletes the account and logs out', async () => { + const onLogout = vi.fn() + render() + await userEvent.click(screen.getByText('userSettings.deleteAccount.deleteButton')) + // Confirmation panel appears. + await userEvent.click(screen.getByText('userSettings.deleteAccount.confirmDelete')) + await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/users/me')) + expect(onLogout).toHaveBeenCalled() + }) + + it('can back out of the confirmation', async () => { + render() + await userEvent.click(screen.getByText('userSettings.deleteAccount.deleteButton')) + await userEvent.click(screen.getByText('common.cancel')) + expect(screen.queryByText('userSettings.deleteAccount.confirmDelete')).not.toBeInTheDocument() + }) + + it('does not log out when the delete request fails', async () => { + api.delete.mockRejectedValueOnce(new Error('nope')) + const onLogout = vi.fn() + render() + await userEvent.click(screen.getByText('userSettings.deleteAccount.deleteButton')) + await userEvent.click(screen.getByText('userSettings.deleteAccount.confirmDelete')) + await waitFor(() => expect(api.delete).toHaveBeenCalled()) + // On failure it exits the confirming state and never logs out. + expect(onLogout).not.toHaveBeenCalled() + expect(screen.queryByText('userSettings.deleteAccount.confirmDelete')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/settings/DiceMaterialManagerSection.jsx b/frontend/src/components/settings/DiceMaterialManagerSection.jsx new file mode 100644 index 0000000..c11ae2e --- /dev/null +++ b/frontend/src/components/settings/DiceMaterialManagerSection.jsx @@ -0,0 +1,246 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { LuTrash2, LuPlus } from 'react-icons/lu' +import api from '../../api' +import Spinner from '../Spinner' + +const GROUPS = ['Dice', 'Cards', 'Other', 'Custom'] + +/** + * Admin manager for the dice / materials lookup list. Each entry has a name and + * a group ("Dice", "Cards", "Other", "Custom"); the display is grouped and new + * entries pick their group. Removing an in-use value confirms first (force=true). + */ +export default function DiceMaterialManagerSection() { + const { t } = useTranslation() + const [items, setItems] = useState(null) + const [name, setName] = useState('') + const [group, setGroup] = useState('Custom') + const [confirm, setConfirm] = useState(null) + const [error, setError] = useState('') + + const load = () => { + api + .get('/dice-materials') + .then((r) => setItems(r.dice_materials || [])) + .catch(() => setItems([])) + } + + useEffect(load, []) + + const add = () => { + if (!name.trim()) return + setError('') + api + .post('/dice-materials', { name: name.trim(), group }) + .then(() => { + setName('') + load() + }) + .catch((e) => setError(e?.message || t('lookupSettings.addFailed'))) + } + + const remove = (item, force = false) => { + api + .delete(`/dice-materials/${item.id}${force ? '?force=true' : ''}`) + .then(() => { + setConfirm(null) + load() + }) + .catch((e) => { + const detail = e?.body?.detail + if (e?.status === 409 && detail && typeof detail === 'object') { + setConfirm({ + id: item.id, + name: detail.name || item.name, + count: detail.usage_count || 0, + }) + } else { + setError(e?.message || t('lookupSettings.removeFailed')) + } + }) + } + + if (items === null) return + + // Group the items for display, preserving the canonical group order with any + // unexpected groups appended after. + const byGroup = {} + for (const item of items) { + const g = item.group || 'Custom' + ;(byGroup[g] = byGroup[g] || []).push(item) + } + const orderedGroups = [ + ...GROUPS.filter((g) => byGroup[g]), + ...Object.keys(byGroup).filter((g) => !GROUPS.includes(g)), + ] + + return ( +
+
+ setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && add()} + placeholder={t('lookupSettings.diceNamePlaceholder')} + aria-label={t('lookupSettings.diceNamePlaceholder')} + style={{ flex: '1 1 160px', minWidth: 0 }} + /> + + +
+ + {error &&
{error}
} + +
+ {orderedGroups.map((g) => ( +
+
+ {t(`lookupSettings.diceGroup.${g.toLowerCase()}`, { defaultValue: g })} +
+
+ {byGroup[g].map((item) => ( + + {item.name} + + + ))} +
+
+ ))} +
+ + {confirm && ( +
+
+

+ {t('lookupSettings.removeTitle', { name: confirm.name })} +

+

+ {t('lookupSettings.inUseWarning', { name: confirm.name, count: confirm.count })} +

+
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/components/settings/DiceMaterialManagerSection.test.jsx b/frontend/src/components/settings/DiceMaterialManagerSection.test.jsx new file mode 100644 index 0000000..14d37c1 --- /dev/null +++ b/frontend/src/components/settings/DiceMaterialManagerSection.test.jsx @@ -0,0 +1,53 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import DiceMaterialManagerSection from './DiceMaterialManagerSection' +import api from '../../api' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (k, o) => (o?.defaultValue ? o.defaultValue : o ? `${k}:${JSON.stringify(o)}` : k), + }), +})) +vi.mock('../Spinner', () => ({ default: () =>
spinner
})) +vi.mock('../../api', () => ({ default: { get: vi.fn(), post: vi.fn(), delete: vi.fn() } })) + +const items = [ + { id: 'd1', name: 'D20', group: 'Dice', is_default: true }, + { id: 'c1', name: 'Tarot Cards', group: 'Cards', is_default: true }, + { id: 'x1', name: 'Spinner', group: 'Custom', is_default: false }, +] + +beforeEach(() => { + vi.clearAllMocks() + api.get.mockResolvedValue({ dice_materials: items }) + api.post.mockResolvedValue({ id: 'n1', name: 'Fudge Dice', group: 'Dice' }) + api.delete.mockResolvedValue({ status: 'ok' }) +}) + +describe('DiceMaterialManagerSection', () => { + it('lists items grouped by their group', async () => { + render() + expect(await screen.findByText('D20')).toBeInTheDocument() + expect(screen.getByText('Tarot Cards')).toBeInTheDocument() + expect(screen.getByText('Spinner')).toBeInTheDocument() + // "Dice" appears both as a group heading and a select option (>1 match). + expect(screen.getAllByText('Dice').length).toBeGreaterThan(1) + }) + + it('creates an item with the selected group', async () => { + render() + await screen.findByText('D20') + await userEvent.type(screen.getByLabelText('lookupSettings.diceNamePlaceholder'), 'Fudge Dice') + await userEvent.selectOptions(screen.getByLabelText('lookupSettings.diceGroupLabel'), 'Dice') + await userEvent.click(screen.getByText('lookupSettings.add')) + expect(api.post).toHaveBeenCalledWith('/dice-materials', { name: 'Fudge Dice', group: 'Dice' }) + }) + + it('removes an item', async () => { + render() + await screen.findByText('Spinner') + await userEvent.click(screen.getByLabelText('common.remove Spinner')) + expect(api.delete).toHaveBeenCalledWith('/dice-materials/x1') + }) +}) diff --git a/frontend/src/components/settings/DisplayNameSection.test.jsx b/frontend/src/components/settings/DisplayNameSection.test.jsx new file mode 100644 index 0000000..76db12f --- /dev/null +++ b/frontend/src/components/settings/DisplayNameSection.test.jsx @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import DisplayNameSection from './DisplayNameSection' +import api from '../../api' + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k) => k }) })) +vi.mock('../Spinner', () => ({ default: () => })) + +const refreshUser = vi.fn() +let mockUser = { username: 'gm', display_name: 'Game Master' } +vi.mock('../../context/AuthContext', () => ({ + useAuth: () => ({ user: mockUser, refreshUser }), +})) +vi.mock('../../api', () => ({ default: { patch: vi.fn(() => Promise.resolve({})) } })) + +beforeEach(() => { + vi.clearAllMocks() + mockUser = { username: 'gm', display_name: 'Game Master' } +}) + +describe('DisplayNameSection', () => { + it('seeds the input from the current display name', () => { + render() + expect(screen.getByLabelText('userSettings.displayName.label').value).toBe('Game Master') + }) + + it('saves a trimmed display name and refreshes the user', async () => { + render() + const input = screen.getByLabelText('userSettings.displayName.label') + await userEvent.clear(input) + await userEvent.type(input, ' New Name ') + await userEvent.click(screen.getByText('userSettings.displayName.save')) + await waitFor(() => + expect(api.patch).toHaveBeenCalledWith('/users/me/preferences', { display_name: 'New Name' }) + ) + expect(refreshUser).toHaveBeenCalled() + }) + + it('shows an error message when saving fails', async () => { + api.patch.mockRejectedValueOnce(new Error('boom')) + render() + await userEvent.click(screen.getByText('userSettings.displayName.save')) + await waitFor(() => expect(screen.getByText('boom')).toBeInTheDocument()) + }) +}) diff --git a/frontend/src/components/settings/EmailSection.test.jsx b/frontend/src/components/settings/EmailSection.test.jsx new file mode 100644 index 0000000..4e5bf74 --- /dev/null +++ b/frontend/src/components/settings/EmailSection.test.jsx @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import EmailSection from './EmailSection' +import api from '../../api' + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k) => k }) })) +vi.mock('../Spinner', () => ({ default: () => })) + +const refreshUser = vi.fn() +let mockUser = { username: 'gm', email: 'gm@example.com' } +vi.mock('../../context/AuthContext', () => ({ + useAuth: () => ({ user: mockUser, refreshUser }), +})) +vi.mock('../../api', () => ({ default: { patch: vi.fn(() => Promise.resolve({})) } })) + +beforeEach(() => { + vi.clearAllMocks() + mockUser = { username: 'gm', email: 'gm@example.com' } +}) + +describe('EmailSection', () => { + it('seeds the input from the current email', () => { + render() + expect(screen.getByLabelText('userSettings.email.label').value).toBe('gm@example.com') + }) + + it('saves the trimmed email and refreshes the user', async () => { + render() + const input = screen.getByLabelText('userSettings.email.label') + await userEvent.clear(input) + await userEvent.type(input, ' new@example.com ') + await userEvent.click(screen.getByText('common.save')) + await waitFor(() => + expect(api.patch).toHaveBeenCalledWith('/users/me/preferences', { + email: 'new@example.com', + }) + ) + expect(refreshUser).toHaveBeenCalled() + }) + + it('surfaces an error when the save fails', async () => { + api.patch.mockRejectedValueOnce(new Error('bad email')) + render() + await userEvent.click(screen.getByText('common.save')) + await waitFor(() => expect(screen.getByText('bad email')).toBeInTheDocument()) + }) +}) diff --git a/frontend/src/components/settings/ExplicitContentSection.test.jsx b/frontend/src/components/settings/ExplicitContentSection.test.jsx new file mode 100644 index 0000000..c3359bf --- /dev/null +++ b/frontend/src/components/settings/ExplicitContentSection.test.jsx @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ExplicitContentSection from './ExplicitContentSection' +import api from '../../api' + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k) => k }) })) +vi.mock('../Spinner', () => ({ default: () => })) + +const refreshUser = vi.fn() +let mockUser = { allow_explicit: true } +vi.mock('../../context/AuthContext', () => ({ + useAuth: () => ({ user: mockUser, refreshUser }), +})) +vi.mock('../../api', () => ({ default: { patch: vi.fn(() => Promise.resolve({})) } })) + +beforeEach(() => { + vi.clearAllMocks() + mockUser = { allow_explicit: true } +}) + +describe('ExplicitContentSection', () => { + it('reflects the current allow_explicit state', () => { + render() + expect(screen.getByRole('checkbox')).toBeChecked() + }) + + it('toggles the preference off and refreshes the user', async () => { + render() + await userEvent.click(screen.getByRole('checkbox')) + await waitFor(() => + expect(api.patch).toHaveBeenCalledWith('/users/me/preferences', { allow_explicit: false }) + ) + expect(refreshUser).toHaveBeenCalled() + }) + + it('defaults to allowed when the flag is undefined', () => { + mockUser = {} + render() + expect(screen.getByRole('checkbox')).toBeChecked() + }) +}) diff --git a/frontend/src/components/settings/GenreManagerSection.jsx b/frontend/src/components/settings/GenreManagerSection.jsx new file mode 100644 index 0000000..b899a1e --- /dev/null +++ b/frontend/src/components/settings/GenreManagerSection.jsx @@ -0,0 +1,217 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { LuTrash2, LuPlus } from 'react-icons/lu' +import api from '../../api' +import Spinner from '../Spinner' +import { buildGenreTree } from '../metadata/metadataUtils' + +/** + * Admin panel to manage the genre lookup list: add tiered genres and remove + * any (default or custom). Removing a genre that is attached to systems/books + * requires confirmation and re-issues the delete with force=true. + */ +export default function GenreManagerSection() { + const { t } = useTranslation() + const [genres, setGenres] = useState(null) + const [name, setName] = useState('') + const [parentId, setParentId] = useState('') + const [confirm, setConfirm] = useState(null) // { id, name, count } + const [error, setError] = useState('') + + const load = () => { + api + .get('/genres') + .then((r) => setGenres(r.genres || [])) + .catch(() => setGenres([])) + } + + useEffect(load, []) + + const add = () => { + if (!name.trim()) return + setError('') + api + .post('/genres', { name: name.trim(), parent_id: parentId || null }) + .then(() => { + setName('') + setParentId('') + load() + }) + .catch((e) => setError(e?.message || t('lookupSettings.addFailed'))) + } + + const remove = (g, force = false) => { + api + .delete(`/genres/${g.id}${force ? '?force=true' : ''}`) + .then(() => { + setConfirm(null) + load() + }) + .catch((e) => { + // 409 → in use; surface a confirm modal with the usage count. + const detail = e?.body?.detail + if (e?.status === 409 && detail && typeof detail === 'object') { + setConfirm({ id: g.id, name: detail.name || g.name, count: detail.usage_count || 0 }) + } else { + setError(e?.message || t('lookupSettings.removeFailed')) + } + }) + } + + if (genres === null) return + + const tree = buildGenreTree(genres) + + return ( +
+
+ setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && add()} + placeholder={t('lookupSettings.namePlaceholder')} + aria-label={t('lookupSettings.namePlaceholder')} + style={{ flex: '1 1 160px', minWidth: 0 }} + /> + + +
+ + {error &&
{error}
} + +
+ {tree.map((g) => ( +
+ + {g.depth > 0 && '└ '} + {g.name} + + +
+ ))} +
+ + {confirm && ( +
+
+

+ {t('lookupSettings.removeTitle', { name: confirm.name })} +

+

+ {t('lookupSettings.inUseWarning', { name: confirm.name, count: confirm.count })} +

+
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/components/settings/GenreManagerSection.test.jsx b/frontend/src/components/settings/GenreManagerSection.test.jsx new file mode 100644 index 0000000..8b6f6ed --- /dev/null +++ b/frontend/src/components/settings/GenreManagerSection.test.jsx @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import GenreManagerSection from './GenreManagerSection' +import api from '../../api' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (k, o) => (o ? `${k}:${JSON.stringify(o)}` : k) }), +})) +vi.mock('../Spinner', () => ({ default: () =>
spinner
})) +vi.mock('../../api', () => ({ + default: { get: vi.fn(), post: vi.fn(), delete: vi.fn() }, +})) + +const genres = [ + { id: 'g1', name: 'Fantasy', parent_id: null, is_default: true, sort_order: 1 }, + { id: 'g2', name: 'Grimdark', parent_id: 'g1', is_default: true, sort_order: 1 }, +] + +beforeEach(() => { + vi.clearAllMocks() + api.get.mockResolvedValue({ genres }) + api.post.mockResolvedValue({ id: 'g3', name: 'New' }) + api.delete.mockResolvedValue({ status: 'ok' }) +}) + +describe('GenreManagerSection', () => { + it('lists genres after load', async () => { + render() + // "Fantasy" appears in both the parent dropdown and the list, so assert + // via the unique remove button label instead. + expect(await screen.findByLabelText('common.remove Fantasy')).toBeInTheDocument() + expect(screen.getByLabelText('common.remove Grimdark')).toBeInTheDocument() + }) + + it('creates a genre', async () => { + render() + await screen.findByLabelText('common.remove Fantasy') + await userEvent.type(screen.getByLabelText('lookupSettings.namePlaceholder'), 'Western') + await userEvent.click(screen.getByText('lookupSettings.add')) + expect(api.post).toHaveBeenCalledWith('/genres', { name: 'Western', parent_id: null }) + }) + + it('deletes an unused genre directly', async () => { + render() + await screen.findByLabelText('common.remove Fantasy') + await userEvent.click(screen.getByLabelText('common.remove Fantasy')) + await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/genres/g1')) + }) + + it('shows a confirm modal when a genre is in use, then force-deletes', async () => { + api.delete.mockRejectedValueOnce( + Object.assign(new Error('conflict'), { + status: 409, + body: { detail: { name: 'Fantasy', usage_count: 3 } }, + }) + ) + render() + await screen.findByLabelText('common.remove Fantasy') + await userEvent.click(screen.getByLabelText('common.remove Fantasy')) + // Confirm modal appears with usage count. + expect(await screen.findByRole('dialog')).toBeInTheDocument() + api.delete.mockResolvedValueOnce({ status: 'ok', removed_usage: 3 }) + await userEvent.click(screen.getByText('lookupSettings.confirmRemove')) + await waitFor(() => expect(api.delete).toHaveBeenLastCalledWith('/genres/g1?force=true')) + }) +}) diff --git a/frontend/src/components/settings/LevelBadge.test.jsx b/frontend/src/components/settings/LevelBadge.test.jsx new file mode 100644 index 0000000..02737af --- /dev/null +++ b/frontend/src/components/settings/LevelBadge.test.jsx @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import LevelBadge from './LevelBadge' + +describe('LevelBadge', () => { + it('renders a coloured, labelled badge for a known level', () => { + render() + const el = screen.getByText('ERROR') + expect(el).toHaveAttribute('aria-label', 'Log level: ERROR') + expect(el.style.color).toBeTruthy() + }) + + it('renders the raw level for an unknown level', () => { + render() + expect(screen.getByText('TRACE')).toBeInTheDocument() + }) + + it('uses a lighter weight for DEBUG', () => { + render() + expect(screen.getByText('DEBUG').style.fontWeight).toBe('400') + }) +}) diff --git a/frontend/src/components/settings/LicenseManagerSection.jsx b/frontend/src/components/settings/LicenseManagerSection.jsx new file mode 100644 index 0000000..d578913 --- /dev/null +++ b/frontend/src/components/settings/LicenseManagerSection.jsx @@ -0,0 +1,17 @@ +import { useTranslation } from 'react-i18next' +import SimpleLookupManager from './SimpleLookupManager' + +/** + * Admin panel to manage the license lookup list (OGL, ORC, CC-BY, Proprietary, + * …). The section title/description live in the collapsible header. + */ +export default function LicenseManagerSection() { + const { t } = useTranslation() + return ( + + ) +} diff --git a/frontend/src/components/settings/LicenseManagerSection.test.jsx b/frontend/src/components/settings/LicenseManagerSection.test.jsx new file mode 100644 index 0000000..722cc63 --- /dev/null +++ b/frontend/src/components/settings/LicenseManagerSection.test.jsx @@ -0,0 +1,20 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import LicenseManagerSection from './LicenseManagerSection' + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k) => k }) })) + +vi.mock('./SimpleLookupManager', () => ({ + default: ({ endpoint, listKey }) => ( +
+ ), +})) + +describe('LicenseManagerSection', () => { + it('renders SimpleLookupManager pointed at /licenses', () => { + render() + const mgr = screen.getByTestId('mgr') + expect(mgr).toHaveAttribute('data-endpoint', '/licenses') + expect(mgr).toHaveAttribute('data-listkey', 'licenses') + }) +}) diff --git a/frontend/src/components/settings/LogRow.test.jsx b/frontend/src/components/settings/LogRow.test.jsx new file mode 100644 index 0000000..3a6e246 --- /dev/null +++ b/frontend/src/components/settings/LogRow.test.jsx @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import LogRow from './LogRow' + +const entry = (over = {}) => ({ + timestamp: '2026-07-25T09:15:42.123Z', + level: 'INFO', + message: 'Scan complete', + ...over, +}) + +describe('LogRow', () => { + it('renders the time, level badge, and message', () => { + render() + expect(screen.getByText('09:15:42.123')).toBeInTheDocument() + expect(screen.getByText('INFO')).toBeInTheDocument() + expect(screen.getByLabelText('Message: Scan complete')).toBeInTheDocument() + }) + + it('highlights the matched search query in the message', () => { + render() + const mark = screen.getByText('opening') + expect(mark.tagName).toBe('MARK') + }) + + it('renders plain message when the search query does not match', () => { + render() + expect(screen.queryByText('zzz')).not.toBeInTheDocument() + expect(screen.getByLabelText('Message: all good')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/settings/MetadataTab.jsx b/frontend/src/components/settings/MetadataTab.jsx new file mode 100644 index 0000000..e3cba70 --- /dev/null +++ b/frontend/src/components/settings/MetadataTab.jsx @@ -0,0 +1,55 @@ +import { useTranslation } from 'react-i18next' +import CollapsibleSection from './CollapsibleSection' +import GenreManagerSection from './GenreManagerSection' +import SystemFamilyManagerSection from './SystemFamilyManagerSection' +import ParentSystemManagerSection from './ParentSystemManagerSection' +import LicenseManagerSection from './LicenseManagerSection' +import DiceMaterialManagerSection from './DiceMaterialManagerSection' + +/** Admin settings tab: manage the metadata lookup lists, each collapsible. */ +export default function MetadataTab() { + const { t } = useTranslation() + return ( +
+ + + + + + + + + + + + + + + + + + + +
+ ) +} diff --git a/frontend/src/components/settings/MetadataTab.test.jsx b/frontend/src/components/settings/MetadataTab.test.jsx new file mode 100644 index 0000000..412283d --- /dev/null +++ b/frontend/src/components/settings/MetadataTab.test.jsx @@ -0,0 +1,32 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import MetadataTab from './MetadataTab' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (k) => k }), +})) + +vi.mock('./GenreManagerSection', () => ({ default: () =>
genre-manager
})) +vi.mock('./SystemFamilyManagerSection', () => ({ default: () =>
family-manager
})) +vi.mock('./ParentSystemManagerSection', () => ({ default: () =>
parent-manager
})) +vi.mock('./LicenseManagerSection', () => ({ default: () =>
license-manager
})) +vi.mock('./DiceMaterialManagerSection', () => ({ default: () =>
dice-manager
})) + +describe('MetadataTab', () => { + it('renders every metadata manager (all sections open by default)', () => { + render() + expect(screen.getByText('genre-manager')).toBeInTheDocument() + expect(screen.getByText('family-manager')).toBeInTheDocument() + expect(screen.getByText('parent-manager')).toBeInTheDocument() + expect(screen.getByText('license-manager')).toBeInTheDocument() + expect(screen.getByText('dice-manager')).toBeInTheDocument() + }) + + it('collapses a section when its header is clicked', async () => { + const { default: userEvent } = await import('@testing-library/user-event') + render() + // The genres header toggles its manager body. + await userEvent.click(screen.getByRole('button', { name: /lookupSettings.genresTitle/i })) + expect(screen.queryByText('genre-manager')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/settings/OPDSSection.test.jsx b/frontend/src/components/settings/OPDSSection.test.jsx new file mode 100644 index 0000000..2092217 --- /dev/null +++ b/frontend/src/components/settings/OPDSSection.test.jsx @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import OPDSSection from './OPDSSection' +import { opds } from '../../api' + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k) => k }) })) +vi.mock('../Spinner', () => ({ default: () => })) +vi.mock('../../context/UISettingsContext', () => ({ useUISettings: () => ({}) })) + +vi.mock('../../api', () => ({ + opds: { + getStatus: vi.fn(), + generateToken: vi.fn(), + revokeToken: vi.fn(), + }, +})) + +beforeEach(() => { + vi.clearAllMocks() + Object.assign(navigator, { clipboard: { writeText: vi.fn() } }) +}) + +describe('OPDSSection', () => { + it('renders nothing when OPDS is disabled server-side', async () => { + opds.getStatus.mockResolvedValue({ opds_enabled: false }) + const { container } = render() + await waitFor(() => expect(opds.getStatus).toHaveBeenCalled()) + expect(container.querySelector('h3')).toBeNull() + }) + + it('shows the enable button and generates a token', async () => { + opds.getStatus.mockResolvedValue({ opds_enabled: true, has_token: false }) + opds.generateToken.mockResolvedValue({ + opds_enabled: true, + has_token: true, + feed_url: 'http://feed', + }) + render() + await userEvent.click(await screen.findByText('userSettings.opds.enable')) + await waitFor(() => expect(opds.generateToken).toHaveBeenCalled()) + // After generating, the feed URL is shown. + expect(screen.getByLabelText('userSettings.opds.feedUrl').value).toBe('http://feed') + }) + + it('copies the feed URL to the clipboard', async () => { + opds.getStatus.mockResolvedValue({ + opds_enabled: true, + has_token: true, + feed_url: 'http://feed', + }) + render() + await userEvent.click(await screen.findByTitle('userSettings.opds.copy')) + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('http://feed') + expect(screen.getByText('userSettings.opds.copied')).toBeInTheDocument() + }) + + it('confirms and revokes the token', async () => { + opds.getStatus.mockResolvedValue({ + opds_enabled: true, + has_token: true, + feed_url: 'http://feed', + }) + opds.revokeToken.mockResolvedValue({ opds_enabled: true, has_token: false }) + render() + await userEvent.click(await screen.findByText('userSettings.opds.disable')) + await userEvent.click(screen.getByText('userSettings.opds.confirmDisable')) + await waitFor(() => expect(opds.revokeToken).toHaveBeenCalled()) + // Back to the enable button once the token is gone. + expect(screen.getByText('userSettings.opds.enable')).toBeInTheDocument() + }) + + it('can cancel the revoke confirmation', async () => { + opds.getStatus.mockResolvedValue({ + opds_enabled: true, + has_token: true, + feed_url: 'http://feed', + }) + render() + await userEvent.click(await screen.findByText('userSettings.opds.disable')) + await userEvent.click(screen.getByText('common.cancel')) + expect(screen.queryByText('userSettings.opds.confirmDisable')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/settings/ParentSystemManagerSection.jsx b/frontend/src/components/settings/ParentSystemManagerSection.jsx new file mode 100644 index 0000000..9637bc3 --- /dev/null +++ b/frontend/src/components/settings/ParentSystemManagerSection.jsx @@ -0,0 +1,17 @@ +import { useTranslation } from 'react-i18next' +import SimpleLookupManager from './SimpleLookupManager' + +/** + * Admin panel to manage the parent-system lookup list (e.g. "Dungeons & + * Dragons"). The section title/description live in the collapsible header. + */ +export default function ParentSystemManagerSection() { + const { t } = useTranslation() + return ( + + ) +} diff --git a/frontend/src/components/settings/ParentSystemManagerSection.test.jsx b/frontend/src/components/settings/ParentSystemManagerSection.test.jsx new file mode 100644 index 0000000..a47ae80 --- /dev/null +++ b/frontend/src/components/settings/ParentSystemManagerSection.test.jsx @@ -0,0 +1,21 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import ParentSystemManagerSection from './ParentSystemManagerSection' + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k) => k }) })) + +// Assert the wrapper wires the parent-systems endpoint/listKey through. +vi.mock('./SimpleLookupManager', () => ({ + default: ({ endpoint, listKey }) => ( +
+ ), +})) + +describe('ParentSystemManagerSection', () => { + it('renders SimpleLookupManager pointed at /parent-systems', () => { + render() + const mgr = screen.getByTestId('mgr') + expect(mgr).toHaveAttribute('data-endpoint', '/parent-systems') + expect(mgr).toHaveAttribute('data-listkey', 'parent_systems') + }) +}) diff --git a/frontend/src/components/settings/ReaderSection.test.jsx b/frontend/src/components/settings/ReaderSection.test.jsx new file mode 100644 index 0000000..e24904d --- /dev/null +++ b/frontend/src/components/settings/ReaderSection.test.jsx @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ReaderSection from './ReaderSection' +import { getUserPrefs, saveUserPref } from '../../hooks/useUserPrefs' + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k) => k }) })) +vi.mock('../../hooks/useUserPrefs', () => ({ + getUserPrefs: vi.fn(() => ({})), + saveUserPref: vi.fn(), +})) +// SegmentedControl renders a button per option; keep it real but simple. +vi.mock('./SegmentedControl', () => ({ + default: ({ options, onChange }) => ( +
+ {options.map((o) => ( + + ))} +
+ ), +})) + +beforeEach(() => { + vi.clearAllMocks() + getUserPrefs.mockReturnValue({}) +}) + +describe('ReaderSection', () => { + it('saves the reader mode when an option is picked', async () => { + render() + await userEvent.click(screen.getByText('userSettings.reader.spread')) + expect(saveUserPref).toHaveBeenCalledWith('readerMode', 'spread') + }) + + it('toggles the wheel-nav switch and persists it', async () => { + render() + const sw = screen.getByRole('switch') + // Defaults to on (wheelNav !== false). + expect(sw).toHaveAttribute('aria-checked', 'true') + await userEvent.click(sw) + expect(saveUserPref).toHaveBeenCalledWith('wheelNav', false) + expect(sw).toHaveAttribute('aria-checked', 'false') + }) + + it('reflects a stored wheelNav=false preference', () => { + getUserPrefs.mockReturnValue({ wheelNav: false }) + render() + expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false') + }) +}) diff --git a/frontend/src/components/settings/SectionDivider.test.jsx b/frontend/src/components/settings/SectionDivider.test.jsx new file mode 100644 index 0000000..e4faff0 --- /dev/null +++ b/frontend/src/components/settings/SectionDivider.test.jsx @@ -0,0 +1,10 @@ +import { describe, it, expect } from 'vitest' +import { render } from '@testing-library/react' +import SectionDivider from './SectionDivider' + +describe('SectionDivider', () => { + it('renders a bordered divider', () => { + const { container } = render() + expect(container.firstChild.style.borderTop).toBe('1px solid var(--border)') + }) +}) diff --git a/frontend/src/components/settings/SimpleLookupManager.jsx b/frontend/src/components/settings/SimpleLookupManager.jsx new file mode 100644 index 0000000..954e2ff --- /dev/null +++ b/frontend/src/components/settings/SimpleLookupManager.jsx @@ -0,0 +1,211 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { LuTrash2, LuPlus } from 'react-icons/lu' +import api from '../../api' +import Spinner from '../Spinner' + +/** + * Generic manager for a flat name-only lookup list (system families, parent + * systems, licenses). Handles load / add / remove with the shared in-use + * confirmation flow (409 → re-issue with force=true). The parent supplies the + * REST endpoint and the response list key. + * + * Props: + * endpoint – REST base path, e.g. "/parent-systems" + * listKey – key in the GET response holding the array, e.g. "parent_systems" + * addPlaceholder – localized placeholder / aria-label for the name input + */ +export default function SimpleLookupManager({ endpoint, listKey, addPlaceholder }) { + const { t } = useTranslation() + const [items, setItems] = useState(null) + const [name, setName] = useState('') + const [confirm, setConfirm] = useState(null) + const [error, setError] = useState('') + + const load = () => { + api + .get(endpoint) + .then((r) => setItems(r[listKey] || [])) + .catch(() => setItems([])) + } + + useEffect(load, [endpoint, listKey]) + + const add = () => { + if (!name.trim()) return + setError('') + api + .post(endpoint, { name: name.trim() }) + .then(() => { + setName('') + load() + }) + .catch((e) => setError(e?.message || t('lookupSettings.addFailed'))) + } + + const remove = (item, force = false) => { + api + .delete(`${endpoint}/${item.id}${force ? '?force=true' : ''}`) + .then(() => { + setConfirm(null) + load() + }) + .catch((e) => { + const detail = e?.body?.detail + if (e?.status === 409 && detail && typeof detail === 'object') { + setConfirm({ + id: item.id, + name: detail.name || item.name, + count: detail.usage_count || 0, + }) + } else { + setError(e?.message || t('lookupSettings.removeFailed')) + } + }) + } + + if (items === null) return + + return ( +
+
+ setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && add()} + placeholder={addPlaceholder || t('lookupSettings.namePlaceholder')} + aria-label={addPlaceholder || t('lookupSettings.namePlaceholder')} + style={{ flex: '1 1 200px', minWidth: 0 }} + /> + +
+ + {error &&
{error}
} + + {items.length === 0 ? ( +

{t('lookupSettings.empty')}

+ ) : ( +
+ {items.map((item) => ( + + {item.name} + + + ))} +
+ )} + + {confirm && ( +
+
+

+ {t('lookupSettings.removeTitle', { name: confirm.name })} +

+

+ {t('lookupSettings.inUseWarning', { name: confirm.name, count: confirm.count })} +

+
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/components/settings/SimpleLookupManager.test.jsx b/frontend/src/components/settings/SimpleLookupManager.test.jsx new file mode 100644 index 0000000..1288033 --- /dev/null +++ b/frontend/src/components/settings/SimpleLookupManager.test.jsx @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import SimpleLookupManager from './SimpleLookupManager' +import api from '../../api' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (k, o) => (o ? `${k}:${JSON.stringify(o)}` : k) }), +})) +vi.mock('../Spinner', () => ({ default: () =>
spinner
})) +vi.mock('../../api', () => ({ default: { get: vi.fn(), post: vi.fn(), delete: vi.fn() } })) + +const items = [{ id: 'p1', name: 'Dungeons & Dragons', is_default: false, sort_order: 0 }] + +beforeEach(() => { + vi.clearAllMocks() + api.get.mockResolvedValue({ parent_systems: items }) + api.post.mockResolvedValue({ id: 'p2', name: 'Cyberpunk' }) + api.delete.mockResolvedValue({ status: 'ok' }) +}) + +const renderManager = () => + render( + + ) + +describe('SimpleLookupManager', () => { + it('loads and lists items from the endpoint', async () => { + renderManager() + expect(await screen.findByText('Dungeons & Dragons')).toBeInTheDocument() + expect(api.get).toHaveBeenCalledWith('/parent-systems') + }) + + it('shows an empty message when the list is empty', async () => { + api.get.mockResolvedValue({ parent_systems: [] }) + renderManager() + expect(await screen.findByText('lookupSettings.empty')).toBeInTheDocument() + }) + + it('creates an item via the add button', async () => { + renderManager() + await screen.findByText('Dungeons & Dragons') + await userEvent.type(screen.getByLabelText('Parent system name'), 'Cyberpunk') + await userEvent.click(screen.getByText('lookupSettings.add')) + expect(api.post).toHaveBeenCalledWith('/parent-systems', { name: 'Cyberpunk' }) + }) + + it('confirms before force-deleting an in-use item', async () => { + api.delete.mockRejectedValueOnce( + Object.assign(new Error('conflict'), { + status: 409, + body: { detail: { name: 'Dungeons & Dragons', usage_count: 3 } }, + }) + ) + renderManager() + await screen.findByText('Dungeons & Dragons') + await userEvent.click(screen.getByLabelText('common.remove Dungeons & Dragons')) + expect(await screen.findByRole('dialog')).toBeInTheDocument() + api.delete.mockResolvedValueOnce({ status: 'ok' }) + await userEvent.click(screen.getByText('lookupSettings.confirmRemove')) + await waitFor(() => + expect(api.delete).toHaveBeenLastCalledWith('/parent-systems/p1?force=true') + ) + }) +}) diff --git a/frontend/src/components/settings/SystemFamilyManagerSection.jsx b/frontend/src/components/settings/SystemFamilyManagerSection.jsx new file mode 100644 index 0000000..e230a24 --- /dev/null +++ b/frontend/src/components/settings/SystemFamilyManagerSection.jsx @@ -0,0 +1,18 @@ +import { useTranslation } from 'react-i18next' +import SimpleLookupManager from './SimpleLookupManager' + +/** + * Admin panel to manage the system-family lookup list. Thin wrapper over the + * shared SimpleLookupManager; the section title/description live in the + * collapsible header in MetadataTab. + */ +export default function SystemFamilyManagerSection() { + const { t } = useTranslation() + return ( + + ) +} diff --git a/frontend/src/components/settings/SystemFamilyManagerSection.test.jsx b/frontend/src/components/settings/SystemFamilyManagerSection.test.jsx new file mode 100644 index 0000000..6882dbf --- /dev/null +++ b/frontend/src/components/settings/SystemFamilyManagerSection.test.jsx @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import SystemFamilyManagerSection from './SystemFamilyManagerSection' +import api from '../../api' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (k, o) => (o ? `${k}:${JSON.stringify(o)}` : k) }), +})) +vi.mock('../Spinner', () => ({ default: () =>
spinner
})) +vi.mock('../../api', () => ({ + default: { get: vi.fn(), post: vi.fn(), delete: vi.fn() }, +})) + +const families = [{ id: 'f1', name: 'Fate', is_default: true, sort_order: 0 }] + +beforeEach(() => { + vi.clearAllMocks() + api.get.mockResolvedValue({ families }) + api.post.mockResolvedValue({ id: 'f2', name: 'GURPS' }) + api.delete.mockResolvedValue({ status: 'ok' }) +}) + +describe('SystemFamilyManagerSection', () => { + it('lists families', async () => { + render() + expect(await screen.findByText('Fate')).toBeInTheDocument() + }) + + it('creates a family', async () => { + render() + await screen.findByText('Fate') + await userEvent.type(screen.getByLabelText('lookupSettings.namePlaceholder'), 'GURPS') + await userEvent.click(screen.getByText('lookupSettings.add')) + expect(api.post).toHaveBeenCalledWith('/system-families', { name: 'GURPS' }) + }) + + it('confirms before force-deleting an in-use family', async () => { + api.delete.mockRejectedValueOnce( + Object.assign(new Error('conflict'), { + status: 409, + body: { detail: { name: 'Fate', usage_count: 2 } }, + }) + ) + render() + await screen.findByText('Fate') + await userEvent.click(screen.getByLabelText('common.remove Fate')) + expect(await screen.findByRole('dialog')).toBeInTheDocument() + api.delete.mockResolvedValueOnce({ status: 'ok' }) + await userEvent.click(screen.getByText('lookupSettings.confirmRemove')) + await waitFor(() => + expect(api.delete).toHaveBeenLastCalledWith('/system-families/f1?force=true') + ) + }) +}) diff --git a/frontend/src/components/settings/ToolbarButton.test.jsx b/frontend/src/components/settings/ToolbarButton.test.jsx new file mode 100644 index 0000000..e522dc5 --- /dev/null +++ b/frontend/src/components/settings/ToolbarButton.test.jsx @@ -0,0 +1,26 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import ToolbarButton from './ToolbarButton' + +describe('settings/ToolbarButton', () => { + it('renders children with a title and fires onClick', () => { + const onClick = vi.fn() + render( + + B + + ) + const btn = screen.getByTitle('Bold') + expect(btn).toHaveTextContent('B') + fireEvent.click(btn) + expect(onClick).toHaveBeenCalled() + }) + + it('prevents default on mousedown (keeps the editor selection)', () => { + render(I) + const btn = screen.getByTitle('Italic') + const evt = new MouseEvent('mousedown', { bubbles: true, cancelable: true }) + btn.dispatchEvent(evt) + expect(evt.defaultPrevented).toBe(true) + }) +}) diff --git a/frontend/src/components/system/BookBulkEditFields.jsx b/frontend/src/components/system/BookBulkEditFields.jsx new file mode 100644 index 0000000..4e9af82 --- /dev/null +++ b/frontend/src/components/system/BookBulkEditFields.jsx @@ -0,0 +1,271 @@ +import { useTranslation } from 'react-i18next' +import { CATEGORY_ORDER, categoryLabel } from '../../constants' +import GenrePicker from '../metadata/GenrePicker' +import CategoryPicker from '../metadata/CategoryPicker' +import LinkListEditor from '../metadata/LinkListEditor' +import LookupCombobox from '../metadata/LookupCombobox' +import TagPicker from '../metadata/TagPicker' +import useLookups from '../metadata/useLookups' +import { linksForEditing } from '../metadata/metadataUtils' + +// Rich editor body for one book inside the bulk-edit carousel. Mirrors the +// single-book editor and reuses the same shared components (CategoryPicker, +// GenrePicker with inherit-from-system, TagPicker, LinkListEditor). +export default function BookBulkEditFields({ + draft, + setField, + existingCategories = [], + systemGenres = [], +}) { + const { t } = useTranslation() + const { genres: genreTree, licenses, reload: reloadLookups } = useLookups() + const licenseOptions = licenses.map((l) => l.name) + + const categoryOptions = [ + ...new Set([...CATEGORY_ORDER, ...existingCategories, draft.category].filter(Boolean)), + ].map((slug) => ({ value: slug, label: categoryLabel(slug) })) + + const csv = (arr) => (Array.isArray(arr) ? arr.join(', ') : '') + const setCsv = (field, value) => + setField( + field, + value + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + ) + + return ( +
+
+ + setField('title', e.target.value)} + style={input} + /> +
+ +
+ +