From d9b0c59747fc1b54768ee31af3504c97af5137da Mon Sep 17 00:00:00 2001
From: Hunter Read <21973361+hunter-read@users.noreply.github.com>
Date: Mon, 27 Jul 2026 08:33:58 -0700
Subject: [PATCH 1/8] Enhancement: Expand metadata for books and systems, and
add filters/sorting
---
README.md | 39 +-
backend/config.py | 2 +
backend/indexer/__init__.py | 3 +
backend/indexer/categories.py | 32 +
backend/indexer/constants.py | 11 +
backend/indexer/scan.py | 38 +-
backend/main.py | 4 +
.../versions/0004_expand_metadata.py | 256 ++++++
.../migrations/versions/0005_saved_filters.py | 52 ++
.../versions/0006_parent_system_licenses.py | 137 ++++
.../versions/0007_system_folder_cover.py | 41 +
backend/models/__init__.py | 19 +-
backend/models/library.py | 126 +++
backend/models/lookup_defaults.py | 117 +++
backend/models/users.py | 24 +
backend/routers/books/_schemas.py | 49 +-
backend/routers/books/core.py | 10 +
backend/routers/library/core.py | 1 +
backend/routers/lookups/__init__.py | 113 +++
backend/routers/lookups/_helpers.py | 127 +++
backend/routers/lookups/_schemas.py | 66 ++
backend/routers/lookups/core.py | 308 +++++++
backend/routers/saved_filters/__init__.py | 29 +
backend/routers/saved_filters/_schemas.py | 45 ++
backend/routers/saved_filters/core.py | 127 +++
backend/routers/systems/_schemas.py | 34 +
backend/routers/systems/_serializers.py | 77 ++
backend/routers/systems/core.py | 186 +++--
backend/tests/test_db_migrations.py | 77 ++
backend/tests/test_indexer_category.py | 89 ++-
backend/tests/test_indexer_sort_prefix.py | 99 +++
backend/tests/test_lookups.py | 251 ++++++
backend/tests/test_saved_filters.py | 146 ++++
backend/tests/test_systems_metadata.py | 253 ++++++
docs/api.md | 58 +-
docs/data-model.md | 22 +-
frontend/src/components/BulkEditModal.jsx | 84 +-
.../src/components/BulkEditModal.test.jsx | 30 +-
frontend/src/components/BulkToggleButton.jsx | 22 +
.../src/components/BulkToggleButton.test.jsx | 31 +
.../src/components/CollapseExpandButtons.jsx | 29 +
.../components/CollapseExpandButtons.test.jsx | 35 +
frontend/src/components/IconBtn.test.jsx | 30 +
frontend/src/components/TagSection.test.jsx | 39 +
frontend/src/components/ToggleSwitch.jsx | 74 ++
frontend/src/components/ToggleSwitch.test.jsx | 48 ++
frontend/src/components/ToolbarButton.jsx | 51 ++
.../src/components/ToolbarButton.test.jsx | 29 +
.../campaigns/CampaignRoleBadge.test.jsx | 10 +
.../src/components/library/AgnosticChip.jsx | 5 +-
.../components/library/AgnosticChip.test.jsx | 52 ++
.../src/components/library/FavToggle.test.jsx | 24 +
.../src/components/library/FilterModal.jsx | 314 ++++++++
.../components/library/FilterModal.test.jsx | 107 +++
.../src/components/library/SearchInput.jsx | 61 ++
.../components/library/SearchInput.test.jsx | 38 +
.../src/components/library/SortFilterBar.jsx | 295 +++++++
.../components/library/SortFilterBar.test.jsx | 160 ++++
.../src/components/library/SystemCard.jsx | 11 +
.../components/library/applyBookSortFilter.js | 43 +
.../library/applyBookSortFilter.test.js | 79 ++
.../library/applySystemSortFilter.js | 56 ++
.../library/applySystemSortFilter.test.js | 92 +++
.../components/maps/InlineTagEditor.test.jsx | 106 +++
.../src/components/media/GalleryLayout.jsx | 158 ++--
.../components/media/GalleryLayout.test.jsx | 76 +-
.../src/components/media/GalleryToolbar.jsx | 161 +---
.../components/media/GalleryToolbar.test.jsx | 82 ++
.../components/media/TagFilterBar.test.jsx | 60 ++
frontend/src/components/media/mediaConfig.js | 3 +
.../components/metadata/CategoryPicker.jsx | 143 ++++
.../metadata/CategoryPicker.test.jsx | 100 +++
.../metadata/DiceMaterialsPicker.jsx | 263 ++++++
.../metadata/DiceMaterialsPicker.test.jsx | 130 +++
.../src/components/metadata/GenrePicker.jsx | 228 ++++++
.../components/metadata/GenrePicker.test.jsx | 117 +++
.../components/metadata/LinkListEditor.jsx | 81 ++
.../metadata/LinkListEditor.test.jsx | 49 ++
.../components/metadata/LookupCombobox.jsx | 25 +
.../metadata/LookupCombobox.test.jsx | 32 +
.../metadata/MultiSelectDropdown.jsx | 186 +++++
.../metadata/MultiSelectDropdown.test.jsx | 96 +++
.../metadata/SingleSelectCombobox.jsx | 202 +++++
.../metadata/SingleSelectCombobox.test.jsx | 126 +++
.../src/components/metadata/TagChipInput.jsx | 106 +++
.../components/metadata/TagChipInput.test.jsx | 51 ++
.../src/components/metadata/diceMaterials.js | 91 +++
.../components/metadata/diceMaterials.test.js | 81 ++
.../src/components/metadata/metadataUtils.js | 37 +
.../components/metadata/metadataUtils.test.js | 58 ++
.../src/components/metadata/useLookups.js | 45 ++
.../components/metadata/useLookups.test.js | 51 ++
.../components/reader/BookmarkDialog.test.jsx | 77 ++
.../components/reader/SelectionPopup.test.jsx | 21 +
.../settings/CollapsibleSection.jsx | 71 ++
.../settings/CollapsibleSection.test.jsx | 60 ++
.../settings/DeleteAccountSection.test.jsx | 48 ++
.../settings/DiceMaterialManagerSection.jsx | 246 ++++++
.../DiceMaterialManagerSection.test.jsx | 53 ++
.../settings/DisplayNameSection.test.jsx | 46 ++
.../components/settings/EmailSection.test.jsx | 48 ++
.../settings/ExplicitContentSection.test.jsx | 42 +
.../settings/GenreManagerSection.jsx | 217 +++++
.../settings/GenreManagerSection.test.jsx | 67 ++
.../components/settings/LevelBadge.test.jsx | 22 +
.../settings/LicenseManagerSection.jsx | 17 +
.../settings/LicenseManagerSection.test.jsx | 20 +
.../src/components/settings/LogRow.test.jsx | 31 +
.../src/components/settings/MetadataTab.jsx | 55 ++
.../components/settings/MetadataTab.test.jsx | 32 +
.../components/settings/OPDSSection.test.jsx | 84 ++
.../settings/ParentSystemManagerSection.jsx | 17 +
.../ParentSystemManagerSection.test.jsx | 21 +
.../settings/ReaderSection.test.jsx | 52 ++
.../settings/SectionDivider.test.jsx | 10 +
.../settings/SimpleLookupManager.jsx | 211 +++++
.../settings/SimpleLookupManager.test.jsx | 69 ++
.../settings/SystemFamilyManagerSection.jsx | 18 +
.../SystemFamilyManagerSection.test.jsx | 55 ++
.../settings/ToolbarButton.test.jsx | 26 +
.../components/system/BookBulkEditFields.jsx | 275 +++++++
.../system/BookBulkEditFields.test.jsx | 126 +++
frontend/src/components/system/BookEditor.jsx | 248 +++---
.../src/components/system/BookEditor.test.jsx | 92 +--
.../src/components/system/BookFolderGroup.jsx | 6 +
frontend/src/components/system/BookRow.jsx | 20 +
.../components/system/CategoryBookItem.jsx | 2 +
.../components/system/CategoryGroupToggle.jsx | 26 +
.../system/CategoryGroupToggle.test.jsx | 30 +
.../system/SystemBulkEditFields.jsx | 150 +++-
.../system/SystemBulkEditFields.test.jsx | 42 +-
.../system/SystemCategorySection.jsx | 35 +-
.../system/SystemCategorySection.test.jsx | 39 +
.../src/components/system/SystemEditor.jsx | 185 ++++-
.../components/system/SystemEditor.test.jsx | 20 +-
frontend/src/hooks/useMediaGallery.js | 109 ++-
frontend/src/hooks/useMediaGallery.test.js | 176 ++++
frontend/src/hooks/useSavedFilters.js | 70 ++
frontend/src/hooks/useSavedFilters.test.js | 82 ++
frontend/src/locales/de-DE.json | 161 +++-
frontend/src/locales/en-CA.json | 161 +++-
frontend/src/locales/en-US.json | 161 +++-
frontend/src/locales/es-ES.json | 161 +++-
frontend/src/locales/es-MX.json | 161 +++-
frontend/src/locales/fr-CA.json | 161 +++-
frontend/src/locales/fr-FR.json | 161 +++-
frontend/src/locales/nl-NL.json | 161 +++-
frontend/src/locales/pt-BR.json | 161 +++-
frontend/src/locales/pt-PT.json | 161 +++-
frontend/src/utils.js | 10 +-
frontend/src/utils.test.js | 20 +-
frontend/src/utils/acronyms.js | 42 +
frontend/src/utils/acronyms.test.js | 36 +
frontend/src/utils/parentSystemLabel.js | 10 +
frontend/src/utils/parentSystemLabel.test.js | 29 +
frontend/src/utils/systemDisplayName.js | 23 +
frontend/src/utils/systemDisplayName.test.js | 42 +
frontend/src/views/AudioView.test.jsx | 16 +-
frontend/src/views/LibraryView.jsx | 755 +++++++++++-------
frontend/src/views/LibraryView.test.jsx | 127 ++-
frontend/src/views/MapsView.test.jsx | 33 +-
frontend/src/views/SettingsView.jsx | 3 +
frontend/src/views/SettingsView.test.jsx | 46 ++
frontend/src/views/SystemDetailView.jsx | 613 +++++++-------
frontend/src/views/SystemDetailView.test.jsx | 203 ++++-
frontend/src/views/TokensView.test.jsx | 33 +-
166 files changed, 13696 insertions(+), 1285 deletions(-)
create mode 100644 backend/migrations/versions/0004_expand_metadata.py
create mode 100644 backend/migrations/versions/0005_saved_filters.py
create mode 100644 backend/migrations/versions/0006_parent_system_licenses.py
create mode 100644 backend/migrations/versions/0007_system_folder_cover.py
create mode 100644 backend/models/lookup_defaults.py
create mode 100644 backend/routers/lookups/__init__.py
create mode 100644 backend/routers/lookups/_helpers.py
create mode 100644 backend/routers/lookups/_schemas.py
create mode 100644 backend/routers/lookups/core.py
create mode 100644 backend/routers/saved_filters/__init__.py
create mode 100644 backend/routers/saved_filters/_schemas.py
create mode 100644 backend/routers/saved_filters/core.py
create mode 100644 backend/routers/systems/_serializers.py
create mode 100644 backend/tests/test_indexer_sort_prefix.py
create mode 100644 backend/tests/test_lookups.py
create mode 100644 backend/tests/test_saved_filters.py
create mode 100644 backend/tests/test_systems_metadata.py
create mode 100644 frontend/src/components/BulkToggleButton.jsx
create mode 100644 frontend/src/components/BulkToggleButton.test.jsx
create mode 100644 frontend/src/components/CollapseExpandButtons.jsx
create mode 100644 frontend/src/components/CollapseExpandButtons.test.jsx
create mode 100644 frontend/src/components/IconBtn.test.jsx
create mode 100644 frontend/src/components/TagSection.test.jsx
create mode 100644 frontend/src/components/ToggleSwitch.jsx
create mode 100644 frontend/src/components/ToggleSwitch.test.jsx
create mode 100644 frontend/src/components/ToolbarButton.jsx
create mode 100644 frontend/src/components/ToolbarButton.test.jsx
create mode 100644 frontend/src/components/campaigns/CampaignRoleBadge.test.jsx
create mode 100644 frontend/src/components/library/AgnosticChip.test.jsx
create mode 100644 frontend/src/components/library/FavToggle.test.jsx
create mode 100644 frontend/src/components/library/FilterModal.jsx
create mode 100644 frontend/src/components/library/FilterModal.test.jsx
create mode 100644 frontend/src/components/library/SearchInput.jsx
create mode 100644 frontend/src/components/library/SearchInput.test.jsx
create mode 100644 frontend/src/components/library/SortFilterBar.jsx
create mode 100644 frontend/src/components/library/SortFilterBar.test.jsx
create mode 100644 frontend/src/components/library/applyBookSortFilter.js
create mode 100644 frontend/src/components/library/applyBookSortFilter.test.js
create mode 100644 frontend/src/components/library/applySystemSortFilter.js
create mode 100644 frontend/src/components/library/applySystemSortFilter.test.js
create mode 100644 frontend/src/components/maps/InlineTagEditor.test.jsx
create mode 100644 frontend/src/components/media/GalleryToolbar.test.jsx
create mode 100644 frontend/src/components/media/TagFilterBar.test.jsx
create mode 100644 frontend/src/components/metadata/CategoryPicker.jsx
create mode 100644 frontend/src/components/metadata/CategoryPicker.test.jsx
create mode 100644 frontend/src/components/metadata/DiceMaterialsPicker.jsx
create mode 100644 frontend/src/components/metadata/DiceMaterialsPicker.test.jsx
create mode 100644 frontend/src/components/metadata/GenrePicker.jsx
create mode 100644 frontend/src/components/metadata/GenrePicker.test.jsx
create mode 100644 frontend/src/components/metadata/LinkListEditor.jsx
create mode 100644 frontend/src/components/metadata/LinkListEditor.test.jsx
create mode 100644 frontend/src/components/metadata/LookupCombobox.jsx
create mode 100644 frontend/src/components/metadata/LookupCombobox.test.jsx
create mode 100644 frontend/src/components/metadata/MultiSelectDropdown.jsx
create mode 100644 frontend/src/components/metadata/MultiSelectDropdown.test.jsx
create mode 100644 frontend/src/components/metadata/SingleSelectCombobox.jsx
create mode 100644 frontend/src/components/metadata/SingleSelectCombobox.test.jsx
create mode 100644 frontend/src/components/metadata/TagChipInput.jsx
create mode 100644 frontend/src/components/metadata/TagChipInput.test.jsx
create mode 100644 frontend/src/components/metadata/diceMaterials.js
create mode 100644 frontend/src/components/metadata/diceMaterials.test.js
create mode 100644 frontend/src/components/metadata/metadataUtils.js
create mode 100644 frontend/src/components/metadata/metadataUtils.test.js
create mode 100644 frontend/src/components/metadata/useLookups.js
create mode 100644 frontend/src/components/metadata/useLookups.test.js
create mode 100644 frontend/src/components/reader/BookmarkDialog.test.jsx
create mode 100644 frontend/src/components/reader/SelectionPopup.test.jsx
create mode 100644 frontend/src/components/settings/CollapsibleSection.jsx
create mode 100644 frontend/src/components/settings/CollapsibleSection.test.jsx
create mode 100644 frontend/src/components/settings/DeleteAccountSection.test.jsx
create mode 100644 frontend/src/components/settings/DiceMaterialManagerSection.jsx
create mode 100644 frontend/src/components/settings/DiceMaterialManagerSection.test.jsx
create mode 100644 frontend/src/components/settings/DisplayNameSection.test.jsx
create mode 100644 frontend/src/components/settings/EmailSection.test.jsx
create mode 100644 frontend/src/components/settings/ExplicitContentSection.test.jsx
create mode 100644 frontend/src/components/settings/GenreManagerSection.jsx
create mode 100644 frontend/src/components/settings/GenreManagerSection.test.jsx
create mode 100644 frontend/src/components/settings/LevelBadge.test.jsx
create mode 100644 frontend/src/components/settings/LicenseManagerSection.jsx
create mode 100644 frontend/src/components/settings/LicenseManagerSection.test.jsx
create mode 100644 frontend/src/components/settings/LogRow.test.jsx
create mode 100644 frontend/src/components/settings/MetadataTab.jsx
create mode 100644 frontend/src/components/settings/MetadataTab.test.jsx
create mode 100644 frontend/src/components/settings/OPDSSection.test.jsx
create mode 100644 frontend/src/components/settings/ParentSystemManagerSection.jsx
create mode 100644 frontend/src/components/settings/ParentSystemManagerSection.test.jsx
create mode 100644 frontend/src/components/settings/ReaderSection.test.jsx
create mode 100644 frontend/src/components/settings/SectionDivider.test.jsx
create mode 100644 frontend/src/components/settings/SimpleLookupManager.jsx
create mode 100644 frontend/src/components/settings/SimpleLookupManager.test.jsx
create mode 100644 frontend/src/components/settings/SystemFamilyManagerSection.jsx
create mode 100644 frontend/src/components/settings/SystemFamilyManagerSection.test.jsx
create mode 100644 frontend/src/components/settings/ToolbarButton.test.jsx
create mode 100644 frontend/src/components/system/BookBulkEditFields.jsx
create mode 100644 frontend/src/components/system/BookBulkEditFields.test.jsx
create mode 100644 frontend/src/components/system/CategoryGroupToggle.jsx
create mode 100644 frontend/src/components/system/CategoryGroupToggle.test.jsx
create mode 100644 frontend/src/hooks/useMediaGallery.test.js
create mode 100644 frontend/src/hooks/useSavedFilters.js
create mode 100644 frontend/src/hooks/useSavedFilters.test.js
create mode 100644 frontend/src/utils/acronyms.js
create mode 100644 frontend/src/utils/acronyms.test.js
create mode 100644 frontend/src/utils/parentSystemLabel.js
create mode 100644 frontend/src/utils/parentSystemLabel.test.js
create mode 100644 frontend/src/utils/systemDisplayName.js
create mode 100644 frontend/src/utils/systemDisplayName.test.js
create mode 100644 frontend/src/views/SettingsView.test.jsx
diff --git a/README.md b/README.md
index f187330..636351d 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,8 @@ A Docker-based web application for managing your tabletop RPG PDF collection. Br
- **Bookmarks** - Per-user page and text-selection bookmarks with inline highlights
- **Favorites** - Save systems, books, maps, tokens, and audio for quick access
- **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 +236,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 +280,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.
diff --git a/backend/config.py b/backend/config.py
index d3127a5..0514092 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
@@ -307,6 +308,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..4aad2c7 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"}
diff --git a/backend/indexer/scan.py b/backend/indexer/scan.py
index 68ac82d..edb978d 100644
--- a/backend/indexer/scan.py
+++ b/backend/indexer/scan.py
@@ -35,8 +35,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,
)
from .constants import (
ARCHIVE_EXTS,
@@ -177,6 +180,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 +198,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 +213,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 +232,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 +251,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 +262,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 +335,7 @@ def _scan_books_in_system(
system,
system_name,
system_category_off,
- is_agnostic,
+ is_special_collection,
root,
filename,
filepath,
@@ -334,7 +360,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,
@@ -384,7 +410,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)
diff --git a/backend/main.py b/backend/main.py
index 0f97bbc..65e8a12 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -34,10 +34,12 @@
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,
@@ -228,6 +230,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 +238,7 @@ def health():
api.include_router(search_router.router)
api.include_router(campaigns_router.router)
api.include_router(favorites_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/models/__init__.py b/backend/models/__init__.py
index c3eec7d..a7a1282 100644
--- a/backend/models/__init__.py
+++ b/backend/models/__init__.py
@@ -22,10 +22,19 @@
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 .users import Bookmark, Favorite, SavedFilter, User
__all__ = [
"Base",
@@ -34,6 +43,11 @@
"GameSystem",
"Book",
"BookFolder",
+ "Genre",
+ "SystemFamily",
+ "ParentSystem",
+ "License",
+ "DiceMaterial",
# Media
"GenericMap",
"MapFolder",
@@ -45,6 +59,7 @@
"User",
"Bookmark",
"Favorite",
+ "SavedFilter",
# Campaigns
"Campaign",
"CampaignMember",
diff --git a/backend/models/library.py b/backend/models/library.py
index b296e4d..d25e5e9 100644
--- a/backend/models/library.py
+++ b/backend/models/library.py
@@ -25,13 +25,40 @@ 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,9 +81,27 @@ 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")
@@ -99,3 +144,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/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/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..3a61d24 100644
--- a/backend/routers/books/core.py
+++ b/backend/routers/books/core.py
@@ -89,9 +89,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": book.tags or [],
"indexed": book.indexed,
"index_failed": book.index_failed,
"ocr_indexed": book.index_error == "ocr",
diff --git a/backend/routers/library/core.py b/backend/routers/library/core.py
index a586919..fcb15f8 100644
--- a/backend/routers/library/core.py
+++ b/backend/routers/library/core.py
@@ -89,6 +89,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(),
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/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/systems/_schemas.py b/backend/routers/systems/_schemas.py
index 1ecd970..422b1be 100644
--- a/backend/routers/systems/_schemas.py
+++ b/backend/routers/systems/_schemas.py
@@ -11,6 +11,13 @@ 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]
@@ -25,9 +32,20 @@ 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
@@ -35,3 +53,19 @@ class GameSystemUpdate(BaseModel):
@classmethod
def lowercase_tags(cls, v):
return _normalize_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..a73086d
--- /dev/null
+++ b/backend/routers/systems/_serializers.py
@@ -0,0 +1,77 @@
+"""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) -> dict[str, Any]:
+ """Serialize a Book to the API shape used by the system detail view."""
+ 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": book.tags or [],
+ "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
+) -> dict[str, Any]:
+ """Serialize a GameSystem for the systems list (no book payload)."""
+ 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": system.tags or [],
+ "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..37a3882 100644
--- a/backend/routers/systems/core.py
+++ b/backend/routers/systems/core.py
@@ -1,5 +1,8 @@
"""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
@@ -7,9 +10,33 @@
from ...models import Book, BookFolder, GameSystem, User
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 +45,71 @@ 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()
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)
)
+
+ 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 +128,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 +138,42 @@ 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)
+
+ 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,
+ )
+ summary["books"] = [serialize_book(b) 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(
@@ -156,9 +212,9 @@ 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"]]
for field, value in payload.items():
setattr(system, field, value)
db.commit()
diff --git a/backend/tests/test_db_migrations.py b/backend/tests/test_db_migrations.py
index 6a47ab1..1fd81f1 100644
--- a/backend/tests/test_db_migrations.py
+++ b/backend/tests/test_db_migrations.py
@@ -432,3 +432,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_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_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_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_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/docs/api.md b/docs/api.md
index f8bf0d8..a9c21d8 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -209,21 +209,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 +245,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 |
@@ -290,6 +324,22 @@ Audio tracks behave like maps/tokens, with embedded metadata. Supported formats:
Item types: `book`, `map`, `token`, `audio`, `system`
+### 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
Bookmarks are per-user - users cannot see or modify each other's bookmarks.
diff --git a/docs/data-model.md b/docs/data-model.md
index e72682e..3ae1b9c 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
@@ -61,16 +64,19 @@ erDiagram
## 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`, 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` |
@@ -106,9 +112,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`. |
+| `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` | Tags auto-applied to a book subcategory folder path. | `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)
@@ -129,6 +140,7 @@ 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)`. |
### Campaigns - [`backend/models/campaigns.py`](../backend/models/campaigns.py)
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' ? (
+ {description} +
+ )} + {children} ++ {t('lookupSettings.inUseWarning', { name: confirm.name, count: confirm.count })} +
++ {t('lookupSettings.inUseWarning', { name: confirm.name, count: confirm.count })} +
+{t('lookupSettings.empty')}
+ ) : ( ++ {t('lookupSettings.inUseWarning', { name: confirm.name, count: confirm.count })} +
+