diff --git a/README.md b/README.md
index f187330..e717b6d 100644
--- a/README.md
+++ b/README.md
@@ -29,8 +29,10 @@ A Docker-based web application for managing your tabletop RPG PDF collection. Br
- **Global Audio Player** - A persistent pop-out player that keeps playing while you navigate. Build a local queue by playing a whole folder, queueing tracks one at a time ("Play Next"), having a GM play a campaign resource group, or playing all the audio embedded in a wiki note. Expand it to see and reorder upcoming tracks, with a repeat-current-track toggle
- **Bookmarks** - Per-user page and text-selection bookmarks with inline highlights
- **Favorites** - Save systems, books, maps, tokens, and audio for quick access
+- **Shared Tags** - One tag catalog across systems, books, maps, tokens, and audio. Tags match on a lowercased internal key with an editable display name, so "Draw Steel" and "draw steel" are the same tag. A dedicated Tags page lists every tag with usage counts, lets you rename/merge/delete, and browses all items carrying a tag; clicking a tag anywhere jumps there. Filter dropdowns show only tags used on the current page, and campaign resources can be bulk-added by tag
- **View Modes** - Toggle the systems, books, maps, tokens, and audio grids between card, compact, and list layouts; each content type remembers its own default (configurable in Account Settings) while the in-page toggle is a per-tab override. Cards and list rows include quick download and favorite buttons.
-- **Metadata Editor** - Add descriptions, tags, genre, publisher links, and character builder URLs
+- **Metadata Editor** - Rich metadata for systems (multiple genres, dice/materials, system family, parent system + edition, license, year, and multiple generic + character-builder links) and books (authors, artists, genres, ISBN, version, language, a per-book license override, a variable-precision publication date, and multiple links). Genres, system families, parent systems, licenses, and dice/materials are drawn from curated lists you manage in **Settings → Metadata** (each section collapsible; defaults plus your own custom values). A *parent system* groups related systems (e.g. D&D 5e and AD&D under "Dungeons & Dragons"), and an *edition* string combines with it for display ("Cyberpunk" + "Red" → "Cyberpunk Red")
+- **Sort & Filter** - Sort systems by name, book count, total page count, or year, and books by title, page count, or year. A shared filter modal covers genre, system family, parent system, edition, dice/materials, tags, favourites, and explicit content. Named filter presets are saved to your account (server-side, so they follow you across devices), and one preset per view can be set as the default you land on
- **Bulk Actions** - Multi-select books, maps, tokens, and audio (click, shift-click for a range, ⌘/Ctrl-click to toggle) then bulk tag, add to a campaign, or edit metadata via a carousel
- **Campaigns** - Track GM-run and personal campaigns; a markdown notes wiki with deep linking, Markdown/JSON/LegendKeeper import & export, character art and sheets, linked resources, and scheduling
- **OPDS Catalog** - Each user can generate a personal OPDS feed URL to connect e-reader apps directly to their library
@@ -235,19 +237,22 @@ Archive files placed anywhere under `books/` are shown alongside your books in t
Archives are treated as opaque downloads - Grimoire does not extract or read their contents, so clicking one downloads the file rather than opening the reader. They're also included when you download a whole system, category, or subfolder as an archive. Comic-book archives (`.cbz`, `.cbr`, `.cb7`, `.cbt`) additionally get a cover thumbnail generated from the first image inside them.
-#### System-agnostic collections
+#### Special collections (system-agnostic & one-page)
-Some books don't belong to a single game system - reference material, zines, art books, or rulesets like Ironsworn or Mothership that span multiple systems. Create a folder whose name is one of the recognized system-agnostic names and Grimoire will display its contents in a separate **System-Agnostic** section on the library page, outside the normal game-system grid.
+Some books don't belong to a single game system - reference material, zines, art books, or rulesets like Ironsworn or Mothership that span multiple systems. And some "systems" are really a bucket of many tiny games: one-page and small RPGs. Create a folder whose name is one of the recognized names below and Grimoire will display its contents in a separate **Special Collections** section on the library page, outside the normal game-system grid.
**Recognized folder names** (case-insensitive):
-| Folder name | Example |
-|---|---|
-| `System Agnostic` | `books/System Agnostic/` |
-| `Generic` | `books/Generic/` |
-| `Any` | `books/Any/` |
+| Folder name | Collection | Example |
+|---|---|---|
+| `System Agnostic` | System-agnostic | `books/System Agnostic/` |
+| `Generic` | System-agnostic | `books/Generic/` |
+| `Any` | System-agnostic | `books/Any/` |
+| `One-Page RPGs` | One-page / small RPGs | `books/One-Page RPGs/` |
+| `Single-Page RPGs` | One-page / small RPGs | `books/Single-Page RPGs/` |
+| `One-Shot RPGs` | One-page / small RPGs | `books/One-Shot RPGs/` |
-Subfolders directly under the agnostic root become **custom category headings** - whatever you name them is what appears in the UI. There is no keyword matching; the folder name is used as-is (slugified).
+Subfolders directly under one of these roots become **custom category headings** - whatever you name them is what appears in the UI. There is no keyword matching; the folder name is used as-is (slugified).
```
books/
@@ -276,6 +281,23 @@ books/
Users with explicit content disabled will not see this system or its books.
+#### Sort-order prefixes
+
+To pull a system to the top of an alphabetically-sorted file browser, you can
+prefix its folder name with `!`, `$`, or `%`. Grimoire strips a leading run of
+those characters when deriving the system name (only the leading run — internal
+occurrences are kept):
+
+```
+books/
+├── !!Dungeons & Dragons/ → "Dungeons & Dragons"
+├── !system-agnostic/ → still the System-Agnostic collection
+└── $%Pathfinder 2e/ → "Pathfinder 2e"
+```
+
+The prefix stacks with `(nsfw)`, so `!!Forbidden Lore (NSFW)` becomes the
+explicit system "Forbidden Lore".
+
### Book metadata from OPF files
Grimoire reads [OPF](https://idpf.org/epub/20/spec/OPF_2.0.1_draft.htm) sidecar files to populate book metadata automatically on first scan. OPF files are the format used by [Calibre](https://calibre-ebook.com/) and many other library managers.
@@ -367,18 +389,25 @@ Drop a `tags.json` file into any `maps/`, `tokens/`, or `audio/` folder (or subf
| `"subfolder"` | A subfolder |
| `"subfolder/file.png"` | A file inside a subfolder |
-Values are arrays of tag strings.
+Values are arrays of tag strings. The casing you write is used as the tag's
+display name the first time it's seen.
```json
{
- ".": ["dungeon", "fantasy"],
- "cave-entrance.png": ["cave", "outdoors"],
- "boss-arena": ["combat", "finale"],
- "boss-arena/throne-room.png": ["throne", "indoor"]
+ ".": ["Dungeon", "Fantasy"],
+ "cave-entrance.png": ["Cave", "Outdoors"],
+ "boss-arena": ["Combat", "Finale"],
+ "boss-arena/throne-room.png": ["Throne", "Indoor"]
}
```
-Tags are applied (or updated) every time the library is rescanned. Tags set via the web UI are replaced by the values in `tags.json` on the next scan.
+`tags.json` is **additive and read-only**: on every rescan it only *adds* the
+tags it lists — it never removes tags you set (or removed) in the web UI, and it
+never overwrites a tag's display name once the tag exists. A new tag is created
+using the casing in the file; renaming a tag later in the web UI sticks, because
+the display name lives in the app's tag catalog rather than in `tags.json` (which
+the app treats as read-only and never rewrites). Tags are matched
+case-insensitively, so `"dungeon"` and `"Dungeon"` are the same tag.
---
diff --git a/backend/config.py b/backend/config.py
index d3127a5..bf0639f 100644
--- a/backend/config.py
+++ b/backend/config.py
@@ -24,6 +24,7 @@
THUMB_DIR = os.path.join(DATA_PATH, "thumbnails")
PAGE_CACHE_DIR = os.path.join(DATA_PATH, "page_cache")
CAMPAIGN_UPLOAD_DIR = os.path.join(DATA_PATH, "campaign_uploads")
+SYSTEM_COVER_DIR = os.path.join(DATA_PATH, "system_covers")
VALKEY_URL = os.environ.get("VALKEY_URL", "")
# OCR: image-only PDFs (scanned pages with no embedded text layer) can be run
@@ -44,6 +45,13 @@
OCR_ENABLED = os.environ.get("OCR_ENABLED", "true").lower() == "true"
OCR_LANGUAGES = os.environ.get("OCR_LANGUAGES", "eng").strip() or "eng"
+# Set true to disable the "update available" check that proxies GitHub's
+# releases API. When disabled, /api/latest-release always returns null and no
+# outbound request to GitHub is ever made.
+DISABLE_VERSION_CHECKING = (
+ os.environ.get("DISABLE_VERSION_CHECKING", "false").lower() == "true"
+)
+
def _read_ocr_concurrency() -> int:
"""Parallel-OCR worker count. 0 = OCR disabled; negatives clamp to 0; bad
@@ -307,6 +315,7 @@ def clear(self) -> None:
os.makedirs(os.path.join(CAMPAIGN_UPLOAD_DIR, "art"), exist_ok=True)
os.makedirs(os.path.join(CAMPAIGN_UPLOAD_DIR, "sheets"), exist_ok=True)
os.makedirs(os.path.join(CAMPAIGN_UPLOAD_DIR, "files"), exist_ok=True)
+os.makedirs(SYSTEM_COVER_DIR, exist_ok=True)
engine, SessionLocal = init_db(DB_PATH)
diff --git a/backend/indexer/__init__.py b/backend/indexer/__init__.py
index 4edee9b..9bdb5db 100644
--- a/backend/indexer/__init__.py
+++ b/backend/indexer/__init__.py
@@ -46,8 +46,11 @@
agnostic_category,
folder_category_inference_disabled,
guess_category,
+ is_one_page_folder,
+ is_special_collection_folder,
is_system_agnostic_folder,
slugify,
+ strip_sort_prefix,
)
# --- Archive + thumbnail helpers -----------------------------------------------
diff --git a/backend/indexer/categories.py b/backend/indexer/categories.py
index 12ec74b..5938701 100644
--- a/backend/indexer/categories.py
+++ b/backend/indexer/categories.py
@@ -10,12 +10,30 @@
CATEGORY_MAP,
NO_AUTO_CATEGORY_MARKER, # noqa: F401 (re-exported for callers)
UNCATEGORIZED,
+ _ONE_PAGE_SLUGS,
_SYSTEM_AGNOSTIC_SLUGS,
)
logger = logging.getLogger("grimoire.indexer")
+# Leading characters people prepend to system folders purely to steer the
+# alphabetical sort order of their file browser (e.g. "!!Dungeons & Dragons").
+# Only these three are recognized, and only as a contiguous leading run — once a
+# non-special character is read, the rest is the real name.
+_SORT_PREFIX_CHARS = "!$%"
+
+
+def strip_sort_prefix(name: str) -> str:
+ """Strip leading sort-order prefix characters (``!$%``) from a folder name.
+
+ Only the contiguous run of these characters at the very start is removed;
+ everything from the first non-prefix character onward is kept verbatim
+ (including internal ``!``/``$``/``%``). Surrounding whitespace is trimmed.
+ """
+ return name.lstrip(_SORT_PREFIX_CHARS).strip()
+
+
def slugify(name: str) -> str:
"""Create a URL-safe slug from a name."""
slug = name.lower().strip()
@@ -30,6 +48,20 @@ def is_system_agnostic_folder(folder_name: str) -> bool:
return slugify(folder_name) in _SYSTEM_AGNOSTIC_SLUGS
+def is_one_page_folder(folder_name: str) -> bool:
+ """Return True if this top-level books folder is the one-page / small-RPG collection."""
+ return slugify(folder_name) in _ONE_PAGE_SLUGS
+
+
+def is_special_collection_folder(folder_name: str) -> bool:
+ """Return True for any special collection folder (agnostic or one-page).
+
+ Both use their immediate subfolder name as the category label rather than
+ the normal CATEGORY_MAP inference.
+ """
+ return is_system_agnostic_folder(folder_name) or is_one_page_folder(folder_name)
+
+
def _normalize_folder(name: str) -> str:
"""Collapse hyphens, underscores, and whitespace to a single space for category matching."""
return re.sub(r"[-_\s]+", " ", name.lower()).strip()
diff --git a/backend/indexer/constants.py b/backend/indexer/constants.py
index e90a6e4..af1db7b 100644
--- a/backend/indexer/constants.py
+++ b/backend/indexer/constants.py
@@ -67,6 +67,17 @@
}
)
+# Normalized folder names treated as the "one-page / small RPG" collection — a
+# special sibling of the system-agnostic collection (issue #202). Books here use
+# their immediate subfolder name as the category label, exactly like agnostic.
+_ONE_PAGE_SLUGS = frozenset(
+ {
+ "one-page-rpgs",
+ "single-page-rpgs",
+ "one-shot-rpgs",
+ }
+)
+
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".svg"}
PDF_EXTS = {".pdf"}
DOC_EXTS = {".pdf", ".epub", ".djvu"}
@@ -120,4 +131,6 @@
METADATA_MODES = ("new", "missing", "replace")
# Book fields that can be sourced from an OPF sidecar.
-_OPF_BOOK_FIELDS = ("title", "authors", "description", "publisher", "year", "tags")
+# Note: OPF ``tags`` are applied separately via the shared-tag service (issue
+# #235); they are intentionally NOT in this setattr list (no column to set).
+_OPF_BOOK_FIELDS = ("title", "authors", "description", "publisher", "year")
diff --git a/backend/indexer/scan.py b/backend/indexer/scan.py
index 68ac82d..8ff3350 100644
--- a/backend/indexer/scan.py
+++ b/backend/indexer/scan.py
@@ -30,13 +30,17 @@
GenericMap,
Token,
)
+from ..services import tag_service
from ._subprocess import _run_with_timeout
from .categories import (
agnostic_category,
folder_category_inference_disabled,
guess_category,
+ is_one_page_folder,
+ is_special_collection_folder,
is_system_agnostic_folder,
slugify,
+ strip_sort_prefix,
)
from .constants import (
ARCHIVE_EXTS,
@@ -177,6 +181,10 @@ def _scan_books(ctx: _ScanContext, books_dir: Path) -> None:
raw_name = system_dir.name
is_nsfw = bool(re.search(r"\(nsfw\)", raw_name, re.IGNORECASE))
system_name = re.sub(r"\s*\(nsfw\)\s*", "", raw_name, flags=re.IGNORECASE).strip()
+ # Strip any leading sort-order prefix (!$%) people use to steer their file
+ # browser's alphabetical ordering — "!!Dungeons & Dragons" → "Dungeons & Dragons".
+ # This must happen before slug/name/special-collection derivation.
+ system_name = strip_sort_prefix(system_name)
system_slug = slugify(system_name)
logger.debug(f"DB: querying system '{system_slug}'")
@@ -191,6 +199,10 @@ def _scan_books(ctx: _ScanContext, books_dir: Path) -> None:
stats["errors"] += 1
continue
is_agnostic = is_system_agnostic_folder(system_name)
+ is_one_page = is_one_page_folder(system_name)
+ # Both special collections (agnostic + one-page) use immediate-subfolder
+ # names as category labels rather than CATEGORY_MAP inference.
+ is_special = is_special_collection_folder(system_name)
# Per-system opt-out: a marker file at the system root disables
# folder-name category inference for just this system.
system_category_off = category_inference_off or (
@@ -202,6 +214,7 @@ def _scan_books(ctx: _ScanContext, books_dir: Path) -> None:
slug=system_slug,
is_explicit=is_nsfw,
is_system_agnostic=is_agnostic,
+ is_one_page=is_one_page,
)
session.add(system)
logger.debug(f"DB: flushing new system '{system_name}'")
@@ -220,6 +233,16 @@ def _scan_books(ctx: _ScanContext, books_dir: Path) -> None:
system.is_explicit = True
if is_agnostic and not system.is_system_agnostic:
system.is_system_agnostic = True
+ if is_one_page and not system.is_one_page:
+ system.is_one_page = True
+
+ # Folder cover convention: a cover.*/folder.* image at the system root
+ # becomes the system's cover (precedence: folder > uploaded > book cover).
+ # Stored library-relative so it survives moves of the whole library dir.
+ artwork = _find_folder_artwork(str(system_dir))
+ new_folder_cover = os.path.relpath(artwork, ctx.library_path) if artwork else ""
+ if (system.folder_cover_path or "") != new_folder_cover:
+ system.folder_cover_path = new_folder_cover
# When scoped to a path deeper than the system dir, walk only that
# subtree; otherwise walk the whole system.
@@ -229,7 +252,7 @@ def _scan_books(ctx: _ScanContext, books_dir: Path) -> None:
else system_dir
)
stop = _scan_books_in_system(
- ctx, system, system_name, system_category_off, is_agnostic, walk_root
+ ctx, system, system_name, system_category_off, is_special, walk_root
)
if stop:
return
@@ -240,10 +263,14 @@ def _scan_books_in_system(
system: GameSystem,
system_name: str,
system_category_off: bool,
- is_agnostic: bool,
+ is_special_collection: bool,
walk_root: Path,
) -> bool:
- """Walk one system's tree and register its books. Returns True if stop requested."""
+ """Walk one system's tree and register its books. Returns True if stop requested.
+
+ ``is_special_collection`` is True for the system-agnostic and one-page
+ collections, which label categories by immediate subfolder name.
+ """
session = ctx.session
ignore = ctx.ignore
stats = ctx.stats
@@ -309,7 +336,7 @@ def _scan_books_in_system(
system,
system_name,
system_category_off,
- is_agnostic,
+ is_special_collection,
root,
filename,
filepath,
@@ -334,7 +361,7 @@ def _register_book(
system: GameSystem,
system_name: str,
system_category_off: bool,
- is_agnostic: bool,
+ is_special_collection: bool,
root: str,
filename: str,
filepath: str,
@@ -354,7 +381,16 @@ def _register_book(
# requested (modes "missing"/"replace") — see _apply_opf_to_book.
if ctx.metadata_mode in ("missing", "replace"):
opf_meta = _find_opf_meta(root, filename)
- if _apply_opf_to_book(existing, opf_meta, ctx.metadata_mode):
+ changed = _apply_opf_to_book(existing, opf_meta, ctx.metadata_mode)
+ # OPF ``tags`` are shared tags (issue #235), applied via the service.
+ # In "missing" mode only fill when the book has no tags yet.
+ opf_tags = opf_meta.get("tags")
+ if opf_tags:
+ current = tag_service.display_tags_for_resource(session, "book", existing.id)
+ if ctx.metadata_mode == "replace" or not current:
+ tag_service.set_resource_tags(session, "book", existing.id, opf_tags)
+ changed = True
+ if changed:
logger.debug(f"Refreshing metadata for '{filename}' (mode={ctx.metadata_mode})")
try:
_run_with_timeout(
@@ -384,7 +420,7 @@ def _register_book(
if system_category_off:
category = UNCATEGORIZED
- elif is_agnostic:
+ elif is_special_collection:
category = agnostic_category(relative_path)
else:
category = guess_category(relative_path)
@@ -420,7 +456,6 @@ def _register_book(
description=opf_meta.get("description"),
publisher=opf_meta.get("publisher"),
year=opf_meta.get("year"),
- tags=opf_meta.get("tags"),
)
# Commit the book record first so that if a subsequent
@@ -430,6 +465,11 @@ def _register_book(
logger.debug(f"DB: committing new book '{filename}'")
try:
_run_with_timeout(session.commit, _DB_TIMEOUT, f"commit book '{filepath}'")
+ # OPF ``subjects`` become shared tags on the book (issue #235).
+ opf_tags = opf_meta.get("tags")
+ if opf_tags:
+ tag_service.set_resource_tags(session, "book", book.id, opf_tags)
+ session.commit()
stats["new_books"] += 1
logger.info(f"Added book: {title} ({category}) in {system_name}")
except TimeoutError as e:
diff --git a/backend/indexer/tags.py b/backend/indexer/tags.py
index 37e3a95..1efdcba 100644
--- a/backend/indexer/tags.py
+++ b/backend/indexer/tags.py
@@ -20,6 +20,7 @@
Token,
TokenFolder,
)
+from ..services import tag_service
from .categories import slugify
from .metadata import resolve_collection_dir
@@ -43,13 +44,17 @@ def _load_tags_json(folder_path: str) -> dict:
result = {}
for key, val in raw.items():
if isinstance(val, list):
+ # Keep the entered casing (dedupe by lowercased key). It becomes
+ # the default display for a brand-new tag; the tag service never
+ # overwrites an existing tag's display, so a rescan is safe.
seen: set[str] = set()
normalized = []
for t in val:
- lowered = str(t).strip().lower()
+ stripped = str(t).strip()
+ lowered = stripped.lower()
if lowered and lowered not in seen:
seen.add(lowered)
- normalized.append(lowered)
+ normalized.append(stripped)
result[key] = normalized
return result
except Exception as exc:
@@ -57,6 +62,33 @@ def _load_tags_json(folder_path: str) -> dict:
return {}
+def _apply_folder_tags(
+ session: Session,
+ folder_model: type,
+ folder_rel: str,
+ tags: list[str],
+ resource_type: str,
+) -> None:
+ """Additively apply ``tags.json`` tags to a folder record.
+
+ The library is read-only, so ``tags.json`` only *adds* tags: catalog rows are
+ registered (new tags keep their entered casing, existing displays are left
+ alone) and the folder's stored internal keys are the union of what's there and
+ the new keys — user-set folder tags are never removed by a rescan.
+ """
+ internals = tag_service.register_folder_tags(session, tags, category=resource_type)
+ record = session.query(folder_model).filter_by(path=folder_rel).first()
+ if record:
+ existing = [tag_service.normalize_internal(t) for t in (record.tags or [])]
+ merged = list(existing)
+ for key in internals:
+ if key not in merged:
+ merged.append(key)
+ record.tags = merged
+ else:
+ session.add(folder_model(path=folder_rel, tags=internals))
+
+
def _within_scope(path: Path, scope_dir: Path | None) -> bool:
"""Return True if `path` is the scope dir or lives under it (or scope is None)."""
if scope_dir is None:
@@ -81,6 +113,8 @@ def _apply_tags_from_library(
"tokens": (TokenFolder, Token),
"audio": (AudioFolder, Audio),
}
+ # Section name → shared-tag resource_type (issue #235).
+ _section_resource = {"maps": "map", "tokens": "token", "audio": "audio"}
for section in ("maps", "tokens", "audio"):
section_dir = resolve_collection_dir(library, section)
if not section_dir.exists():
@@ -110,30 +144,24 @@ def _apply_tags_from_library(
if not tags:
continue
+ rtype = _section_resource[section]
if key == ".":
folder_rel = str(os.path.relpath(root, section_dir))
- record = session.query(folder_model).filter_by(path=folder_rel).first()
- if record:
- record.tags = tags
- else:
- session.add(folder_model(path=folder_rel, tags=tags))
- logger.debug(f"tags.json: folder {folder_rel} ← {tags}")
+ _apply_folder_tags(session, folder_model, folder_rel, tags, rtype)
+ logger.debug(f"tags.json: folder {folder_rel} += {tags}")
else:
target = root_path / key
if target.is_dir():
folder_rel = str(os.path.relpath(target, section_dir))
- record = session.query(folder_model).filter_by(path=folder_rel).first()
- if record:
- record.tags = tags
- else:
- session.add(folder_model(path=folder_rel, tags=tags))
- logger.debug(f"tags.json: folder {folder_rel} ← {tags}")
+ _apply_folder_tags(session, folder_model, folder_rel, tags, rtype)
+ logger.debug(f"tags.json: folder {folder_rel} += {tags}")
else:
file_rel = os.path.relpath(target, library_path)
record = session.query(file_model).filter_by(relative_path=file_rel).first()
if record:
- record.tags = tags
- logger.debug(f"tags.json: file {file_rel} ← {tags}")
+ # Additive: tags.json only adds item tags (issue #235).
+ tag_service.add_resource_tags(session, rtype, record.id, tags)
+ logger.debug(f"tags.json: file {file_rel} += {tags}")
else:
logger.debug(f"tags.json: no record found for {file_rel}")
@@ -158,7 +186,8 @@ def _apply_tags_from_library(
system_slug = slugify(system_dir.name)
system = session.query(GameSystem).filter_by(slug=system_slug).first()
if system:
- system.tags = tags
- logger.debug(f"tags.json: system {system_dir.name} ← {tags}")
+ # Additive: tags.json only adds system tags (issue #235).
+ tag_service.add_resource_tags(session, "system", system.id, tags)
+ logger.debug(f"tags.json: system {system_dir.name} += {tags}")
session.commit()
diff --git a/backend/main.py b/backend/main.py
index 0f97bbc..5759bfe 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -34,13 +34,16 @@
favorites as favorites_router,
library as library_router,
logs as logs_router,
+ lookups as lookups_router,
maintenance as maintenance_router,
maps as maps_router,
oidc as oidc_router,
opds as opds_router,
+ saved_filters as saved_filters_router,
search as search_router,
settings as settings_router,
systems as systems_router,
+ tags as tags_router,
tokens as tokens_router,
users as users_router,
)
@@ -228,6 +231,7 @@ def health():
api.include_router(users_router.router)
api.include_router(systems_router.router)
api.include_router(books_router.router)
+api.include_router(lookups_router.router)
api.include_router(maps_router.router)
api.include_router(tokens_router.router)
api.include_router(audio_router.router)
@@ -235,6 +239,8 @@ def health():
api.include_router(search_router.router)
api.include_router(campaigns_router.router)
api.include_router(favorites_router.router)
+api.include_router(tags_router.router)
+api.include_router(saved_filters_router.router)
api.include_router(bookmarks_router.router)
api.include_router(downloads_router.router)
api.include_router(export_router.router)
diff --git a/backend/migrations/versions/0004_expand_metadata.py b/backend/migrations/versions/0004_expand_metadata.py
new file mode 100644
index 0000000..7401324
--- /dev/null
+++ b/backend/migrations/versions/0004_expand_metadata.py
@@ -0,0 +1,256 @@
+"""expand system & book metadata; genre/system-family lookups (issue #202)
+
+Additive schema changes only (SQLite-safe — no column drops):
+
+game_systems: genres, dice_materials, system_family, license, year, urls,
+ character_builder_urls, is_one_page
+books: artists, genres, isbn, version, language, month, day, urls
+
+New lookup tables ``genres`` (tiered via parent_id) and ``system_families``,
+seeded with defaults. Backfills the new multi-value columns from the legacy
+single-value ones (game_systems.genre / .character_builder_url,
+books.publisher_url).
+
+Revision ID: 6be3e9a796c4
+Revises: b2e5d3f0c8a1
+Create Date: 2026-07-25 00:00:00.000000+00:00
+
+"""
+import json
+import uuid
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import inspect
+
+from backend.models.lookup_defaults import DEFAULT_GENRES, DEFAULT_SYSTEM_FAMILIES
+
+
+# revision identifiers, used by Alembic.
+revision: str = "6be3e9a796c4"
+down_revision: Union[str, None] = "b2e5d3f0c8a1"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def _columns(table: str) -> set:
+ return {c["name"] for c in inspect(op.get_bind()).get_columns(table)}
+
+
+def _tables() -> set:
+ return set(inspect(op.get_bind()).get_table_names())
+
+
+def _add(table: str, column: sa.Column) -> None:
+ """Add a column only if it isn't already present (idempotent retries)."""
+ if column.name not in _columns(table):
+ op.add_column(table, column)
+
+
+def upgrade() -> None:
+ op.execute("DROP TABLE IF EXISTS _alembic_tmp_books")
+ op.execute("DROP TABLE IF EXISTS _alembic_tmp_game_systems")
+
+ # --- game_systems columns ---
+ _add("game_systems", sa.Column("genres", sa.JSON(), nullable=True))
+ _add("game_systems", sa.Column("dice_materials", sa.JSON(), nullable=True))
+ _add(
+ "game_systems",
+ sa.Column("system_family", sa.String(length=150), nullable=True, server_default=""),
+ )
+ _add(
+ "game_systems",
+ sa.Column("license", sa.String(length=100), nullable=True, server_default=""),
+ )
+ _add("game_systems", sa.Column("year", sa.Integer(), nullable=True))
+ _add("game_systems", sa.Column("urls", sa.JSON(), nullable=True))
+ _add("game_systems", sa.Column("character_builder_urls", sa.JSON(), nullable=True))
+ _add(
+ "game_systems",
+ sa.Column("is_one_page", sa.Boolean(), nullable=True, server_default=sa.text("0")),
+ )
+
+ # --- books columns ---
+ _add("books", sa.Column("artists", sa.JSON(), nullable=True))
+ _add("books", sa.Column("genres", sa.JSON(), nullable=True))
+ _add("books", sa.Column("isbn", sa.String(length=20), nullable=True, server_default=""))
+ _add("books", sa.Column("version", sa.String(length=50), nullable=True, server_default=""))
+ _add("books", sa.Column("language", sa.String(length=20), nullable=True, server_default=""))
+ _add("books", sa.Column("month", sa.Integer(), nullable=True))
+ _add("books", sa.Column("day", sa.Integer(), nullable=True))
+ _add("books", sa.Column("urls", sa.JSON(), nullable=True))
+
+ # --- lookup tables ---
+ tables = _tables()
+ if "genres" not in tables:
+ op.create_table(
+ "genres",
+ sa.Column("id", sa.String(length=36), nullable=False),
+ sa.Column("name", sa.String(length=120), nullable=False),
+ sa.Column("parent_id", sa.String(length=36), nullable=True),
+ sa.Column("is_default", sa.Boolean(), nullable=True),
+ sa.Column("sort_order", sa.Integer(), nullable=True),
+ sa.ForeignKeyConstraint(["parent_id"], ["genres.id"]),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("name"),
+ )
+ op.create_index("ix_genres_parent_id", "genres", ["parent_id"])
+ if "system_families" not in tables:
+ op.create_table(
+ "system_families",
+ sa.Column("id", sa.String(length=36), nullable=False),
+ sa.Column("name", sa.String(length=150), nullable=False),
+ sa.Column("is_default", sa.Boolean(), nullable=True),
+ sa.Column("sort_order", sa.Integer(), nullable=True),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("name"),
+ )
+
+ _seed_lookups()
+ _backfill()
+
+
+def _seed_lookups() -> None:
+ """Insert default genres/families. Skips names that already exist."""
+ bind = op.get_bind()
+
+ existing_genres = {
+ row[0] for row in bind.execute(sa.text("SELECT name FROM genres")).fetchall()
+ }
+ order = 0
+ for name, children in DEFAULT_GENRES:
+ order += 1
+ parent_id = _ensure_genre(bind, existing_genres, name, None, order)
+ child_order = 0
+ for child_name, _grandchildren in children:
+ child_order += 1
+ _ensure_genre(bind, existing_genres, child_name, parent_id, child_order)
+
+ existing_families = {
+ row[0]
+ for row in bind.execute(sa.text("SELECT name FROM system_families")).fetchall()
+ }
+ for idx, fam in enumerate(DEFAULT_SYSTEM_FAMILIES):
+ if fam in existing_families:
+ continue
+ bind.execute(
+ sa.text(
+ "INSERT INTO system_families (id, name, is_default, sort_order) "
+ "VALUES (:id, :name, 1, :sort_order)"
+ ),
+ {"id": str(uuid.uuid4()), "name": fam, "sort_order": idx},
+ )
+
+
+def _ensure_genre(bind, existing: set, name: str, parent_id, sort_order: int) -> str:
+ """Insert a genre if absent; return its id either way."""
+ if name in existing:
+ row = bind.execute(
+ sa.text("SELECT id FROM genres WHERE name = :name"), {"name": name}
+ ).fetchone()
+ return row[0]
+ new_id = str(uuid.uuid4())
+ bind.execute(
+ sa.text(
+ "INSERT INTO genres (id, name, parent_id, is_default, sort_order) "
+ "VALUES (:id, :name, :parent_id, 1, :sort_order)"
+ ),
+ {"id": new_id, "name": name, "parent_id": parent_id, "sort_order": sort_order},
+ )
+ existing.add(name)
+ return new_id
+
+
+def _backfill() -> None:
+ """Populate new multi-value columns from the legacy single-value ones."""
+ bind = op.get_bind()
+
+ # game_systems.genre -> genres; character_builder_url -> character_builder_urls
+ rows = bind.execute(
+ sa.text(
+ "SELECT id, genre, character_builder_url, genres, "
+ "character_builder_urls, urls FROM game_systems"
+ )
+ ).fetchall()
+ for gid, genre, cb_url, genres, cb_urls, urls in rows:
+ updates = {}
+ if _empty(genres) and genre:
+ updates["genres"] = json.dumps([genre])
+ if _empty(cb_urls) and cb_url:
+ updates["character_builder_urls"] = json.dumps(
+ [{"label": "", "url": cb_url}]
+ )
+ if _empty(genres) and _empty(urls):
+ updates.setdefault("urls", json.dumps([]))
+ # Ensure JSON list columns are never left NULL.
+ _default_json(updates, "genres", genres)
+ _default_json(updates, "dice_materials", None)
+ _default_json(updates, "urls", urls)
+ _default_json(updates, "character_builder_urls", cb_urls)
+ if updates:
+ _apply_update(bind, "game_systems", gid, updates)
+
+ # books.publisher_url -> urls
+ rows = bind.execute(
+ sa.text("SELECT id, publisher_url, urls, genres, artists FROM books")
+ ).fetchall()
+ for bid, pub_url, urls, genres, artists in rows:
+ updates = {}
+ if _empty(urls) and pub_url:
+ updates["urls"] = json.dumps([{"label": "Publisher", "url": pub_url}])
+ _default_json(updates, "urls", urls)
+ _default_json(updates, "genres", genres)
+ _default_json(updates, "artists", artists)
+ if updates:
+ _apply_update(bind, "books", bid, updates)
+
+
+def _empty(raw) -> bool:
+ if raw is None:
+ return True
+ try:
+ val = json.loads(raw) if isinstance(raw, str) else raw
+ except (ValueError, TypeError):
+ return True
+ return not val
+
+
+def _default_json(updates: dict, key: str, current) -> None:
+ """Ensure a JSON list column gets an empty-list default when NULL."""
+ if key not in updates and current is None:
+ updates[key] = json.dumps([])
+
+
+def _apply_update(bind, table: str, row_id: str, updates: dict) -> None:
+ set_clause = ", ".join(f"{k} = :{k}" for k in updates)
+ params = dict(updates)
+ params["row_id"] = row_id
+ bind.execute(
+ sa.text(f"UPDATE {table} SET {set_clause} WHERE id = :row_id"), params
+ )
+
+
+def downgrade() -> None:
+ tables = _tables()
+ if "system_families" in tables:
+ op.drop_table("system_families")
+ if "genres" in tables:
+ op.drop_index("ix_genres_parent_id", table_name="genres")
+ op.drop_table("genres")
+
+ for col in ("artists", "genres", "isbn", "version", "language", "month", "day", "urls"):
+ if col in _columns("books"):
+ op.drop_column("books", col)
+ for col in (
+ "genres",
+ "dice_materials",
+ "system_family",
+ "license",
+ "year",
+ "urls",
+ "character_builder_urls",
+ "is_one_page",
+ ):
+ if col in _columns("game_systems"):
+ op.drop_column("game_systems", col)
diff --git a/backend/migrations/versions/0005_saved_filters.py b/backend/migrations/versions/0005_saved_filters.py
new file mode 100644
index 0000000..019a220
--- /dev/null
+++ b/backend/migrations/versions/0005_saved_filters.py
@@ -0,0 +1,52 @@
+"""saved_filters: per-user named sort/filter presets with a per-scope default
+
+Adds the ``saved_filters`` table backing server-side saved filters for the
+library scopes (systems/books/maps/tokens/audio). One preset per (user, scope)
+may be the default the user lands on.
+
+Revision ID: 96927e7cb35e
+Revises: 6be3e9a796c4
+Create Date: 2026-07-25 00:00:00.000000+00:00
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import inspect
+
+
+# revision identifiers, used by Alembic.
+revision: str = "96927e7cb35e"
+down_revision: Union[str, None] = "6be3e9a796c4"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def _tables() -> set:
+ return set(inspect(op.get_bind()).get_table_names())
+
+
+def upgrade() -> None:
+ if "saved_filters" not in _tables():
+ op.create_table(
+ "saved_filters",
+ sa.Column("id", sa.String(length=36), nullable=False),
+ sa.Column("user_id", sa.String(length=36), nullable=False),
+ sa.Column("scope", sa.String(length=20), nullable=False),
+ sa.Column("name", sa.String(length=120), nullable=False),
+ sa.Column("state", sa.JSON(), nullable=True),
+ sa.Column("is_default", sa.Boolean(), nullable=True),
+ sa.Column("created_at", sa.DateTime(), nullable=True),
+ sa.Column("updated_at", sa.DateTime(), nullable=True),
+ sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("user_id", "scope", "name"),
+ )
+ op.create_index("ix_saved_filters_user_id", "saved_filters", ["user_id"])
+
+
+def downgrade() -> None:
+ if "saved_filters" in _tables():
+ op.drop_index("ix_saved_filters_user_id", table_name="saved_filters")
+ op.drop_table("saved_filters")
diff --git a/backend/migrations/versions/0006_parent_system_licenses.py b/backend/migrations/versions/0006_parent_system_licenses.py
new file mode 100644
index 0000000..8c62a67
--- /dev/null
+++ b/backend/migrations/versions/0006_parent_system_licenses.py
@@ -0,0 +1,137 @@
+"""parent_system/edition + license & dice/material lookups
+
+Adds:
+ * game_systems.parent_system, game_systems.edition
+ * books.license (per-book override of the system license)
+ * parent_systems, licenses, dice_materials lookup tables (seeded)
+
+All operations are idempotent so partial/retried runs are safe.
+
+Revision ID: 873d3303ba93
+Revises: 96927e7cb35e
+Create Date: 2026-07-26 00:00:00.000000+00:00
+
+"""
+import uuid
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import inspect
+
+from backend.models.lookup_defaults import (
+ DEFAULT_DICE_MATERIALS,
+ DEFAULT_LICENSES,
+ DEFAULT_PARENT_SYSTEMS,
+)
+
+
+# revision identifiers, used by Alembic.
+revision: str = "873d3303ba93"
+down_revision: Union[str, None] = "96927e7cb35e"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def _columns(table: str) -> set:
+ return {c["name"] for c in inspect(op.get_bind()).get_columns(table)}
+
+
+def _tables() -> set:
+ return set(inspect(op.get_bind()).get_table_names())
+
+
+def _add(table: str, column: sa.Column) -> None:
+ if column.name not in _columns(table):
+ op.add_column(table, column)
+
+
+def upgrade() -> None:
+ # --- new columns ---
+ _add(
+ "game_systems",
+ sa.Column("parent_system", sa.String(length=150), nullable=True, server_default=""),
+ )
+ _add(
+ "game_systems",
+ sa.Column("edition", sa.String(length=80), nullable=True, server_default=""),
+ )
+ _add(
+ "books",
+ sa.Column("license", sa.String(length=100), nullable=True, server_default=""),
+ )
+
+ tables = _tables()
+ for name in ("parent_systems", "licenses"):
+ if name not in tables:
+ op.create_table(
+ name,
+ sa.Column("id", sa.String(length=36), nullable=False),
+ sa.Column("name", sa.String(length=150), nullable=False),
+ sa.Column("is_default", sa.Boolean(), nullable=True),
+ sa.Column("sort_order", sa.Integer(), nullable=True),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("name"),
+ )
+ if "dice_materials" not in tables:
+ op.create_table(
+ "dice_materials",
+ sa.Column("id", sa.String(length=36), nullable=False),
+ sa.Column("name", sa.String(length=120), nullable=False),
+ sa.Column("group", sa.String(length=60), nullable=True, server_default="Custom"),
+ sa.Column("is_default", sa.Boolean(), nullable=True),
+ sa.Column("sort_order", sa.Integer(), nullable=True),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("name"),
+ )
+
+ _seed_lookups()
+
+
+def _seed_named(bind, table: str, names: Sequence[str]) -> None:
+ """Seed a simple (id, name, is_default, sort_order) lookup, skipping dupes."""
+ existing = {
+ row[0] for row in bind.execute(sa.text(f"SELECT name FROM {table}")).fetchall()
+ }
+ for idx, name in enumerate(names):
+ if name in existing:
+ continue
+ bind.execute(
+ sa.text(
+ f"INSERT INTO {table} (id, name, is_default, sort_order) "
+ "VALUES (:id, :name, 1, :sort_order)"
+ ),
+ {"id": str(uuid.uuid4()), "name": name, "sort_order": idx},
+ )
+
+
+def _seed_lookups() -> None:
+ bind = op.get_bind()
+ _seed_named(bind, "parent_systems", DEFAULT_PARENT_SYSTEMS)
+ _seed_named(bind, "licenses", DEFAULT_LICENSES)
+
+ existing_dice = {
+ row[0] for row in bind.execute(sa.text("SELECT name FROM dice_materials")).fetchall()
+ }
+ for idx, (group, name) in enumerate(DEFAULT_DICE_MATERIALS):
+ if name in existing_dice:
+ continue
+ bind.execute(
+ sa.text(
+ 'INSERT INTO dice_materials (id, name, "group", is_default, sort_order) '
+ "VALUES (:id, :name, :group, 1, :sort_order)"
+ ),
+ {"id": str(uuid.uuid4()), "name": name, "group": group, "sort_order": idx},
+ )
+
+
+def downgrade() -> None:
+ tables = _tables()
+ for name in ("dice_materials", "licenses", "parent_systems"):
+ if name in tables:
+ op.drop_table(name)
+ if "license" in _columns("books"):
+ op.drop_column("books", "license")
+ for col in ("parent_system", "edition"):
+ if col in _columns("game_systems"):
+ op.drop_column("game_systems", col)
diff --git a/backend/migrations/versions/0007_system_folder_cover.py b/backend/migrations/versions/0007_system_folder_cover.py
new file mode 100644
index 0000000..b4b50ff
--- /dev/null
+++ b/backend/migrations/versions/0007_system_folder_cover.py
@@ -0,0 +1,41 @@
+"""system folder-cover path
+
+Adds game_systems.folder_cover_path — the library-relative path to a
+cover.*/folder.* image found at a system's folder root by the scanner. It takes
+precedence over the admin-uploaded cover_image, which beats the cover_book_id
+fallback. Idempotent.
+
+Revision ID: 1537716d5347
+Revises: 873d3303ba93
+Create Date: 2026-07-26 00:00:00.000000+00:00
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import inspect
+
+
+# revision identifiers, used by Alembic.
+revision: str = "1537716d5347"
+down_revision: Union[str, None] = "873d3303ba93"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def _columns(table: str) -> set:
+ return {c["name"] for c in inspect(op.get_bind()).get_columns(table)}
+
+
+def upgrade() -> None:
+ if "folder_cover_path" not in _columns("game_systems"):
+ op.add_column(
+ "game_systems",
+ sa.Column("folder_cover_path", sa.String(length=1000), nullable=True, server_default=""),
+ )
+
+
+def downgrade() -> None:
+ if "folder_cover_path" in _columns("game_systems"):
+ op.drop_column("game_systems", "folder_cover_path")
diff --git a/backend/migrations/versions/0008_shared_tags.py b/backend/migrations/versions/0008_shared_tags.py
new file mode 100644
index 0000000..2278aa5
--- /dev/null
+++ b/backend/migrations/versions/0008_shared_tags.py
@@ -0,0 +1,173 @@
+"""shared cross-resource tags
+
+Introduces the application-wide tag model (issue #235): a ``tags`` table
+(internal match key + display label) and a polymorphic ``resource_tags`` join.
+Backfills both from the existing per-row JSON ``tags`` arrays on systems, books,
+maps, tokens, and audio. The JSON columns are intentionally left in place for now
+(parallel-run safety); a later migration drops them once nothing reads them.
+
+Display value for a migrated tag defaults to the first non-empty original casing
+seen for that internal key, else the internal key itself. Idempotent: re-running
+neither duplicates tables nor re-links resources.
+
+Revision ID: c7a9e1f2b8d4
+Revises: 1537716d5347
+Create Date: 2026-07-27 00:00:00.000000+00:00
+
+"""
+import json
+import uuid
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import inspect, text
+
+
+# revision identifiers, used by Alembic.
+revision: str = "c7a9e1f2b8d4"
+down_revision: Union[str, None] = "1537716d5347"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+# (source table, resource_type) pairs holding a JSON ``tags`` column + string id.
+_SOURCES = [
+ ("game_systems", "system"),
+ ("books", "book"),
+ ("generic_maps", "map"),
+ ("tokens", "token"),
+ ("audio", "audio"),
+]
+
+
+def _title_case(text_value: str) -> str:
+ """Title-case each whitespace-separated word (keeps existing inner casing per
+ word intact, only upper-casing the first letter). "draw steel" → "Draw Steel"."""
+ return " ".join(w[:1].upper() + w[1:] if w else w for w in text_value.split(" "))
+
+
+def _has_table(bind, name: str) -> bool:
+ return inspect(bind).has_table(name)
+
+
+def _columns(bind, table: str) -> set:
+ return {c["name"] for c in inspect(bind).get_columns(table)}
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+
+ if not _has_table(bind, "tags"):
+ op.create_table(
+ "tags",
+ sa.Column("id", sa.String(length=36), primary_key=True),
+ sa.Column("internal", sa.String(length=200), nullable=False),
+ sa.Column("display", sa.String(length=200), nullable=False),
+ sa.Column("created_at", sa.DateTime(), nullable=True),
+ sa.UniqueConstraint("internal", name="uq_tags_internal"),
+ )
+
+ if not _has_table(bind, "resource_tags"):
+ op.create_table(
+ "resource_tags",
+ sa.Column("id", sa.String(length=36), primary_key=True),
+ sa.Column("tag_id", sa.String(length=36), nullable=False),
+ sa.Column("resource_type", sa.String(length=20), nullable=False),
+ sa.Column("resource_id", sa.String(length=36), nullable=False),
+ sa.Column("created_at", sa.DateTime(), nullable=True),
+ sa.ForeignKeyConstraint(["tag_id"], ["tags.id"], ondelete="CASCADE"),
+ sa.UniqueConstraint(
+ "tag_id", "resource_type", "resource_id", name="uq_resource_tag"
+ ),
+ )
+ op.create_index(
+ "ix_resource_tags_resource",
+ "resource_tags",
+ ["resource_type", "resource_id"],
+ )
+ op.create_index("ix_resource_tags_tag", "resource_tags", ["tag_id"])
+
+ _backfill(bind)
+
+
+def _backfill(bind) -> None:
+ # internal -> tag id (cache within this migration run).
+ tag_ids: dict[str, str] = {
+ row[0]: row[1]
+ for row in bind.execute(text("SELECT internal, id FROM tags")).fetchall()
+ }
+
+ def ensure_tag(raw: str) -> str | None:
+ internal = str(raw).strip().lower()
+ if not internal:
+ return None
+ if internal in tag_ids:
+ return tag_ids[internal]
+ tid = str(uuid.uuid4())
+ stripped = str(raw).strip()
+ # Legacy JSON tags were stored lowercased, so an all-lowercase value has no
+ # meaningful casing — default the display to Title Case. A value that
+ # already carries uppercase is kept as the user entered it.
+ display = stripped if stripped != stripped.lower() else _title_case(stripped)
+ display = display or internal
+ bind.execute(
+ text(
+ "INSERT INTO tags (id, internal, display, created_at) "
+ "VALUES (:id, :internal, :display, CURRENT_TIMESTAMP)"
+ ),
+ {"id": tid, "internal": internal, "display": display},
+ )
+ tag_ids[internal] = tid
+ return tid
+
+ for table, resource_type in _SOURCES:
+ if not _has_table(bind, table) or "tags" not in _columns(bind, table):
+ continue
+ rows = bind.execute(
+ text(f"SELECT id, tags FROM {table} WHERE tags IS NOT NULL")
+ ).fetchall()
+ for res_id, raw in rows:
+ try:
+ tags = json.loads(raw) if isinstance(raw, str) else raw
+ except (ValueError, TypeError):
+ continue
+ if not isinstance(tags, list):
+ continue
+ for raw_tag in tags:
+ tid = ensure_tag(raw_tag)
+ if tid is None:
+ continue
+ # Idempotent link insert (skip if this resource already linked).
+ exists = bind.execute(
+ text(
+ "SELECT 1 FROM resource_tags WHERE tag_id = :tid "
+ "AND resource_type = :rt AND resource_id = :rid"
+ ),
+ {"tid": tid, "rt": resource_type, "rid": res_id},
+ ).first()
+ if exists:
+ continue
+ bind.execute(
+ text(
+ "INSERT INTO resource_tags "
+ "(id, tag_id, resource_type, resource_id, created_at) "
+ "VALUES (:id, :tid, :rt, :rid, CURRENT_TIMESTAMP)"
+ ),
+ {
+ "id": str(uuid.uuid4()),
+ "tid": tid,
+ "rt": resource_type,
+ "rid": res_id,
+ },
+ )
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+ if _has_table(bind, "resource_tags"):
+ op.drop_index("ix_resource_tags_tag", table_name="resource_tags")
+ op.drop_index("ix_resource_tags_resource", table_name="resource_tags")
+ op.drop_table("resource_tags")
+ if _has_table(bind, "tags"):
+ op.drop_table("tags")
diff --git a/backend/migrations/versions/0009_drop_json_item_tags.py b/backend/migrations/versions/0009_drop_json_item_tags.py
new file mode 100644
index 0000000..9894dc3
--- /dev/null
+++ b/backend/migrations/versions/0009_drop_json_item_tags.py
@@ -0,0 +1,78 @@
+"""drop legacy JSON tag columns from item tables
+
+Item tags moved to the shared-tag tables (``tags`` / ``resource_tags``) in 0008,
+and all reads/writes now go through them (issue #235). This drops the now-unused
+JSON ``tags`` column from the five item tables. Folder tables keep their JSON
+``tags`` (folder tagging is a separate, unchanged feature). Idempotent.
+
+The 0008 backfill already copied every JSON tag into the shared tables, so no data
+is lost. ``downgrade`` re-adds the (empty) columns for schema symmetry.
+
+Revision ID: e2b8c6a4d9f1
+Revises: c7a9e1f2b8d4
+Create Date: 2026-07-27 00:00:00.000000+00:00
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import inspect, text
+
+
+revision: str = "e2b8c6a4d9f1"
+down_revision: Union[str, None] = "c7a9e1f2b8d4"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+# Item tables whose per-row JSON ``tags`` column is dropped (folder tables keep it).
+_ITEM_TABLES = ["game_systems", "books", "generic_maps", "tokens", "audio"]
+
+
+def _has_column(table: str, column: str) -> bool:
+ insp = inspect(op.get_bind())
+ if not insp.has_table(table):
+ return False
+ return column in {c["name"] for c in insp.get_columns(table)}
+
+
+def _drop_leftover_tmp(table: str) -> None:
+ """Remove any ``_alembic_tmp_
`` left behind by a previously interrupted
+ batch migration. SQLite's batch (copy-and-move) column change creates this temp
+ table; if an earlier run died mid-flight it lingers and makes every retry fail
+ with "table _alembic_tmp_