From 4a906dc54413eb530e9f743f72b3cf1864857beb Mon Sep 17 00:00:00 2001 From: codebude Date: Thu, 29 Oct 2026 13:48:30 +0100 Subject: [PATCH 1/3] Add backend API for listing tags and batch applying tag changes. --- backend/app/main.py | 3 +- backend/app/routers/books.py | 121 +++++++-- backend/app/routers/tags.py | 117 +++++++++ backend/app/schemas.py | 38 +++ backend/app/services/tags.py | 148 ++++++++++- backend/tests/test_tags.py | 468 +++++++++++++++++++---------------- 6 files changed, 661 insertions(+), 234 deletions(-) create mode 100644 backend/app/routers/tags.py diff --git a/backend/app/main.py b/backend/app/main.py index 99982c25..a94522bd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,7 +13,7 @@ from app._build_info import __git_sha__, __version__ from app.config import settings from app.logging_config import configure_logging -from app.routers import admin, auth, books, config, cover_candidates, covers, data, docs, embed, health, hygiene, import_, oidc, profile, progress, public_profile, sessions, share_links, statistics, users +from app.routers import admin, auth, books, config, cover_candidates, covers, data, docs, embed, health, hygiene, import_, oidc, profile, progress, public_profile, sessions, share_links, statistics, tags, users from app.services.cover_storage import cleanup_orphan_covers from app.services.data_import import cleanup_temp_files from app.services.telemetry import send_telemetry_once @@ -234,6 +234,7 @@ async def proxy_headers_middleware(request: Request, call_next) -> Response: app.include_router(docs.router) app.include_router(health.router) app.include_router(hygiene.router) +app.include_router(tags.router) app.include_router(statistics.router) app.include_router(data.router) app.include_router(config.router) diff --git a/backend/app/routers/books.py b/backend/app/routers/books.py index 52e74692..e0989dd0 100644 --- a/backend/app/routers/books.py +++ b/backend/app/routers/books.py @@ -2,7 +2,7 @@ import logging from datetime import datetime, timezone -from typing import List, Literal, Optional +from typing import Any, List, Literal, Optional from fastapi import APIRouter, Depends, HTTPException, Query, status import sqlalchemy as sa @@ -15,6 +15,7 @@ from app.models import AcquisitionStatus, Author, Book, BookAuthor, BookTag, Medium, ReadingProgress, ReadingSession, ReadingStatus, Tag, User, UserSettings from app.schemas import ( BookCreate, + BookIdListResponse, BookListResponse, BookRead, BookUpdate, @@ -169,6 +170,52 @@ def _build_book_read_with_tags(book: Book, tags_text: str | None, authors: list[ return BookRead.model_validate(payload) +def _apply_book_filters( + base_statement, + *, + status: Optional[ReadingStatus], + acquisition_status: Optional[AcquisitionStatus], + medium: Optional[Medium], + q: Optional[str], + has_cover: Optional[bool], + tag: Optional[str], + exclude_tag: Optional[str], + user_id: int, +) -> Any: + """Apply the standard book filters to a base select statement.""" + if status is not None: + base_statement = base_statement.where(Book.reading_status == status) + + if acquisition_status is not None: + base_statement = base_statement.where(Book.acquisition_status == acquisition_status) + + if medium is not None: + base_statement = base_statement.where(Book.medium == medium) + + if q: + base_statement = apply_search_filter(base_statement, q, user_id) + + if has_cover is not None: + if has_cover: + base_statement = base_statement.where(col(Book.cover_url).is_not(None), Book.cover_url != "") + else: + base_statement = base_statement.where( + sa.or_(col(Book.cover_url).is_(None), col(Book.cover_url) == "") + ) + + if tag: + tag_ids = select(Tag.id).where(Tag.user_id == user_id, Tag.name == tag) + matching = select(BookTag.book_id).where(col(BookTag.tag_id).in_(tag_ids)) + base_statement = base_statement.where(col(Book.id).in_(matching)) + + if exclude_tag: + tag_ids = select(Tag.id).where(Tag.user_id == user_id, Tag.name == exclude_tag) + matching = select(BookTag.book_id).where(col(BookTag.tag_id).in_(tag_ids)) + base_statement = base_statement.where(~col(Book.id).in_(matching)) + + return base_statement + + @router.get("", response_model=BookListResponse) def list_books( status: Optional[ReadingStatus] = Query(default=None), @@ -184,6 +231,8 @@ def list_books( ), ), has_cover: Optional[bool] = Query(default=None), + tag: Optional[str] = Query(default=None, max_length=100, description="Only books carrying this exact tag"), + exclude_tag: Optional[str] = Query(default=None, max_length=100, description="Only books without this exact tag"), sort: Literal["title", "date_added", "date_started", "date_finished", "rating"] = Query( default="date_added" ), @@ -204,28 +253,19 @@ def list_books( "list_books — status=%r acquisition=%r medium=%r q=%r sort=%s order=%s smart_sort=%s", status, acquisition_status, medium, q, sort, order, smart_sort, ) + assert current_user.id is not None base_statement = select(Book).where(Book.user_id == current_user.id) - - if status is not None: - base_statement = base_statement.where(Book.reading_status == status) - - if acquisition_status is not None: - base_statement = base_statement.where(Book.acquisition_status == acquisition_status) - - if medium is not None: - base_statement = base_statement.where(Book.medium == medium) - - if q: - assert current_user.id is not None - base_statement = apply_search_filter(base_statement, q, current_user.id) - - if has_cover is not None: - if has_cover: - base_statement = base_statement.where(col(Book.cover_url).is_not(None), Book.cover_url != "") - else: - base_statement = base_statement.where( - sa.or_(col(Book.cover_url).is_(None), col(Book.cover_url) == "") - ) + base_statement = _apply_book_filters( + base_statement, + status=status, + acquisition_status=acquisition_status, + medium=medium, + q=q, + has_cover=has_cover, + tag=tag, + exclude_tag=exclude_tag, + user_id=current_user.id, + ) total = session.exec( select(func.count()).select_from(base_statement.subquery()) @@ -280,6 +320,43 @@ def list_books( ) +@router.get("/ids", response_model=BookIdListResponse) +def list_book_ids( + status: Optional[ReadingStatus] = Query(default=None), + acquisition_status: Optional[AcquisitionStatus] = Query(default=None), + medium: Optional[Medium] = Query(default=None), + q: Optional[str] = Query(default=None), + has_cover: Optional[bool] = Query(default=None), + tag: Optional[str] = Query(default=None), + exclude_tag: Optional[str] = Query(default=None), + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> BookIdListResponse: + """Return the IDs of all books matching the given filters (no pagination).""" + assert current_user.id is not None + base_statement = select(Book.id).where(Book.user_id == current_user.id) + base_statement = _apply_book_filters( + base_statement, + status=status, + acquisition_status=acquisition_status, + medium=medium, + q=q, + has_cover=has_cover, + tag=tag, + exclude_tag=exclude_tag, + user_id=current_user.id, + ) + base_statement = base_statement.order_by(Book.title.asc()) + rows = session.exec(base_statement).all() + ids = [] + for r in rows: + val = r[0] if isinstance(r, tuple) else r + if val is not None: + ids.append(val) + total = len(ids) + return BookIdListResponse(ids=ids, total=total) + + @router.get("/stats", response_model=LibraryStats) def get_library_stats( current_user: User = Depends(require_user), diff --git a/backend/app/routers/tags.py b/backend/app/routers/tags.py new file mode 100644 index 00000000..94fe8003 --- /dev/null +++ b/backend/app/routers/tags.py @@ -0,0 +1,117 @@ +"""Tag management endpoints — list tags and batch-apply tag changes.""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlmodel import Session, col, select + +from app.schemas import TagListEntry + +from app.auth import require_user +from app.database import get_session +from app.models import Book, User +from app.schemas import TagBatchRequest, TagBatchResponse, TagListResponse +from app.services.tags import apply_batch_tags, list_tags_with_counts, parse_tags + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/tags", tags=["tags"]) + +_MAX_BATCH_SIZE = 500 +_MAX_TAGS_PER_LIST = 50 + + +@router.get("", response_model=TagListResponse) +def list_tags( + q: str = Query(default="", max_length=100, description="Substring filter on tag name"), + limit: int = Query(default=500, ge=1, le=5000), + offset: int = Query(default=0, ge=0), + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> TagListResponse: + """List the current user's tags with usage counts.""" + assert current_user.id is not None + rows, total = list_tags_with_counts(session, current_user.id, q or None, limit, offset) + + return TagListResponse( + tags=[TagListEntry(name=name, count=count) for name, count in rows], + total=total, + ) + + +@router.post("/batch", response_model=TagBatchResponse) +def batch_tags( + req: TagBatchRequest, + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> TagBatchResponse: + """Add and/or remove tags on a set of books in one transaction.""" + if not req.book_ids: + raise HTTPException(status_code=422, detail="book_ids must not be empty") + + deduped_ids = list(dict.fromkeys(req.book_ids)) + if len(deduped_ids) > _MAX_BATCH_SIZE: + raise HTTPException( + status_code=422, + detail=f"At most {_MAX_BATCH_SIZE} books can be updated at once", + ) + + if any("," in str(v) for v in req.add) or any("," in str(v) for v in req.remove): + raise HTTPException( + status_code=422, detail="Tag names must not contain commas" + ) + + add_names = parse_tags(req.add) + remove_names = parse_tags(req.remove) + + if not add_names and not remove_names: + raise HTTPException( + status_code=422, detail="At least one tag must be provided in add or remove" + ) + + if len(add_names) > _MAX_TAGS_PER_LIST or len(remove_names) > _MAX_TAGS_PER_LIST: + raise HTTPException( + status_code=422, + detail=f"At most {_MAX_TAGS_PER_LIST} tags per list", + ) + + overlap = {n.casefold() for n in add_names} & {n.casefold() for n in remove_names} + if overlap: + raise HTTPException( + status_code=422, + detail=f"A tag cannot be both added and removed: {sorted(overlap)}", + ) + + books = session.exec( + select(Book).where( + col(Book.id).in_(deduped_ids), + Book.user_id == current_user.id, + ) + ).all() + + found_ids = {b.id for b in books} + missing_ids = set(deduped_ids) - found_ids + if missing_ids: + raise HTTPException( + status_code=404, + detail=f"Books not found or not owned: {sorted(missing_ids)}", + ) + + try: + assert current_user.id is not None + result = apply_batch_tags(session, current_user.id, list(books), add_names, remove_names) + session.commit() + except Exception: + session.rollback() + logger.exception("Batch tag update failed") + raise HTTPException(status_code=500, detail="Batch tag update failed due to a database error") + + return TagBatchResponse( + matched_books=result.matched_books, + books_changed=result.books_changed, + links_added=result.links_added, + links_removed=result.links_removed, + unchanged_books=result.unchanged_books, + created_tags=result.created_tags, + deleted_tags=result.deleted_tags, + ) \ No newline at end of file diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 0c3f49c6..c5ba63b0 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -247,12 +247,50 @@ class BookListResponse(BaseModel): total: int +class BookIdListResponse(BaseModel): + """List of book IDs matching a filter, with total count.""" + ids: list[int] + total: int + + class TagCloudEntry(SQLModel): """A single tag with its usage count.""" tag: str count: int +class TagListEntry(SQLModel): + """A single tag with its usage count for the tag management views.""" + name: str + count: int + + +class TagListResponse(BaseModel): + """Paginated list of tags with usage counts.""" + tags: list[TagListEntry] + total: int + + +class TagBatchRequest(SQLModel): + """Batch add/remove tags on a set of books.""" + model_config = SQLModelConfig(extra="forbid") + + book_ids: list[int] + add: list[str] = [] + remove: list[str] = [] + + +class TagBatchResponse(SQLModel): + """Result of a batch tag update.""" + matched_books: int + books_changed: int + links_added: int + links_removed: int + unchanged_books: int + created_tags: list[str] = [] + deleted_tags: list[str] = [] + + class SuggestionList(SQLModel): """List of autocomplete suggestions.""" suggestions: list[str] diff --git a/backend/app/services/tags.py b/backend/app/services/tags.py index b8be4fee..0f9e2c2f 100644 --- a/backend/app/services/tags.py +++ b/backend/app/services/tags.py @@ -1,8 +1,9 @@ """Tag parsing, synchronization, and query helpers.""" +from dataclasses import dataclass, field from typing import Optional -from sqlmodel import Session, col, select +from sqlmodel import Session, col, func, select, delete from app.models import Book, BookTag, Tag from app.schemas import BookRead @@ -154,3 +155,148 @@ def build_book_read(session: Session, book: Book) -> BookRead: payload["session_count"] = counts.get("session_count", 0) payload["read_count"] = counts.get("read_count", 0) return BookRead.model_validate(payload) + + +@dataclass +class BatchTagResult: + """Outcome of a batch tag update.""" + matched_books: int = 0 + books_changed: int = 0 + links_added: int = 0 + links_removed: int = 0 + unchanged_books: int = 0 + created_tags: list[str] = field(default_factory=list) + deleted_tags: list[str] = field(default_factory=list) + + +def list_tags_with_counts( + session: Session, user_id: int, q: Optional[str] = None, limit: int = 500, offset: int = 0 +) -> tuple[list[tuple[str, int]], int]: + """Return (tag, count) rows for a user, optionally filtered by substring.""" + base = ( + select(Tag.name, func.count(col(BookTag.book_id)).label("cnt")) + .join(BookTag, col(BookTag.tag_id) == col(Tag.id)) + .join(Book, col(Book.id) == col(BookTag.book_id)) + .where(Tag.user_id == user_id, Book.user_id == user_id) + .group_by(col(Tag.id)) + ) + if q and q.strip(): + from app.services.search import _escape_like + + pattern = f"%{_escape_like(q.strip())}%" + base = base.where(col(Tag.name).ilike(pattern, escape="\\")) + total = session.exec(select(func.count()).select_from(base.subquery())).one() + rows = session.exec( + base.order_by(func.count(col(BookTag.book_id)).desc(), col(Tag.name).asc()) + .offset(offset) + .limit(limit) + ).all() + return [(name, int(count)) for name, count in rows], int(total) + + +def apply_batch_tags( + session: Session, user_id: int, books: list[Book], add: list[str], remove: list[str] +) -> BatchTagResult: + """Apply add/remove tag operations to the given books in one transaction.""" + result = BatchTagResult(matched_books=len(books)) + + add_names = parse_tags(add) + remove_names = parse_tags(remove) + + existing_tags: dict[str, Tag] = {} + all_names = add_names + remove_names + if all_names: + tags = list( + session.exec( + select(Tag).where(Tag.user_id == user_id, col(Tag.name).in_(all_names)) + ).all() + ) + existing_tags = {tag.name: tag for tag in tags} + + created_tags: list[str] = [] + add_tag_ids: dict[str, int] = {} + for name in add_names: + tag = existing_tags.get(name) + if tag is None: + tag = Tag(user_id=user_id, name=name, created_at=utcnow()) + session.add(tag) + session.flush() + existing_tags[name] = tag + created_tags.append(name) + if tag.id is not None: + add_tag_ids[name] = tag.id + + remove_tag_ids: dict[str, int] = {} + for name in remove_names: + tag = existing_tags.get(name) + if tag is not None and tag.id is not None: + remove_tag_ids[name] = tag.id + + affected_tag_ids: set[int] = set(add_tag_ids.values()) | set(remove_tag_ids.values()) + + existing_links: set[tuple[int, int]] = set() + if affected_tag_ids and books: + book_ids = [b.id for b in books if b.id is not None] + rows = session.exec( + select(BookTag.book_id, BookTag.tag_id).where( + col(BookTag.book_id).in_(book_ids), + col(BookTag.tag_id).in_(affected_tag_ids), + ) + ).all() + existing_links = {(bid, tid) for bid, tid in rows} + + target_links: set[tuple[int, int]] = set(existing_links) + for book in books: + if book.id is None: + continue + for tid in add_tag_ids.values(): + target_links.add((book.id, tid)) + for book in books: + if book.id is None: + continue + for tid in remove_tag_ids.values(): + target_links.discard((book.id, tid)) + + to_insert = target_links - existing_links + to_delete = existing_links - target_links + result.links_added = len(to_insert) + result.links_removed = len(to_delete) + + # Apply as set-based statements: remove all affected existing links, then + # insert the full target set. Rows that survive unchanged are deleted and + # re-inserted, which keeps the final state correct and avoids per-pair SQL. + if existing_links: + session.exec( + delete(BookTag).where( + col(BookTag.book_id).in_({lnk[0] for lnk in existing_links}), + col(BookTag.tag_id).in_({lnk[1] for lnk in existing_links}), + ) + ) + if target_links: + session.add_all([BookTag(book_id=bid, tag_id=tid) for bid, tid in target_links]) + + deleted_tags: list[str] = [] + for name, tid in remove_tag_ids.items(): + remaining = session.exec( + select(func.count()).select_from(BookTag).where(BookTag.tag_id == tid) + ).one() + if remaining == 0: + tag = session.get(Tag, tid) + if tag is not None: + session.delete(tag) + deleted_tags.append(name) + result.deleted_tags = deleted_tags + + changed = 0 + for book in books: + if book.id is None: + continue + before = {lnk for lnk in existing_links if lnk[0] == book.id} + after = {lnk for lnk in target_links if lnk[0] == book.id} + if before != after: + changed += 1 + result.books_changed = changed + result.unchanged_books = result.matched_books - changed + result.created_tags = created_tags + + return result diff --git a/backend/tests/test_tags.py b/backend/tests/test_tags.py index 9056d0b9..7292e6d8 100644 --- a/backend/tests/test_tags.py +++ b/backend/tests/test_tags.py @@ -1,215 +1,263 @@ -"""Tests for tag parsing and management.""" +"""Tests for tag listing and batch tag endpoints.""" -from sqlmodel import Session, select +import pytest +from sqlmodel import Session, col, select -from app.models import Book, BookTag, Tag -from app.services.tags import ( - cleanup_orphan_tags, - load_tags_batch, - parse_tags, - sync_book_tags, - tags_text_for_book, -) +from app.models import Book, BookTag, Tag, User +from app.services.tags import apply_batch_tags, list_tags_with_counts, parse_tags -def test_parse_tags_empty_and_none() -> None: - """None and empty strings should produce an empty list.""" - assert parse_tags(None) == [] - assert parse_tags("") == [] - assert parse_tags(" ") == [] - - -def test_parse_tags_single() -> None: - """A single tag should be returned as a one-element list.""" - assert parse_tags("fantasy") == ["fantasy"] - - -def test_parse_tags_multiple() -> None: - """Comma-separated tags should be split into a list.""" - assert parse_tags("fantasy, sci-fi") == ["fantasy", "sci-fi"] - - -def test_parse_tags_strips_whitespace_and_collapses_spaces() -> None: - """Leading/trailing whitespace should be stripped; internal spaces preserved.""" - assert parse_tags(" fantasy , sci fi ") == ["fantasy", "sci fi"] - - -def test_parse_tags_skips_empty_pieces() -> None: - """Empty elements between consecutive commas should be skipped.""" - assert parse_tags("fantasy, , sci-fi") == ["fantasy", "sci-fi"] - - -def test_parse_tags_deduplicates_case_insensitive() -> None: - """Duplicate tags differing only in case should keep the first occurrence.""" - assert parse_tags("Fantasy, fantasy, FANTASY") == ["Fantasy"] - - -def test_sync_book_tags_adds_new_tags(session: Session) -> None: - """New tags should be created and linked to the book.""" - user_id = 1 - book = Book(title="Test", user_id=user_id) - session.add(book) - session.flush() - assert book.id is not None - - sync_book_tags(session, user_id, book.id, "fantasy, sci-fi") - - tags = session.exec(select(Tag).where(Tag.user_id == user_id)).all() - assert len(tags) == 2 - tag_names = {t.name for t in tags} - assert tag_names == {"fantasy", "sci-fi"} - - links = session.exec(select(BookTag).where(BookTag.book_id == book.id)).all() - assert len(links) == 2 - - -def test_sync_book_tags_removes_removed_tags(session: Session) -> None: - """Tags removed from the string should be unlinked.""" - user_id = 1 - book = Book(title="Test", user_id=user_id) - session.add(book) - session.flush() - assert book.id is not None - - sync_book_tags(session, user_id, book.id, "fantasy, sci-fi") - sync_book_tags(session, user_id, book.id, "fantasy") - - links = session.exec(select(BookTag).where(BookTag.book_id == book.id)).all() - assert len(links) == 1 - tag_names = [ - session.exec(select(Tag.name).where(Tag.id == link.tag_id)).one() - for link in links - ] - assert tag_names == ["fantasy"] - - -def test_sync_book_tags_clears_all_when_empty(session: Session) -> None: - """Passing None should clear all tag links.""" - user_id = 1 - book = Book(title="Test", user_id=user_id) - session.add(book) - session.flush() - assert book.id is not None - - sync_book_tags(session, user_id, book.id, "fantasy, sci-fi") - sync_book_tags(session, user_id, book.id, None) - - links = session.exec(select(BookTag).where(BookTag.book_id == book.id)).all() - assert len(links) == 0 - - -def test_sync_book_tags_reuses_existing_tags(session: Session) -> None: - """Existing tags should be reused rather than duplicated.""" - user_id = 1 - existing = Tag(user_id=user_id, name="fantasy") - session.add(existing) - session.flush() - - book = Book(title="Test", user_id=user_id) - session.add(book) - session.flush() - assert book.id is not None - - sync_book_tags(session, user_id, book.id, "fantasy") - - all_tags = session.exec(select(Tag).where(Tag.user_id == user_id)).all() - assert len(all_tags) == 1 - assert all_tags[0].id == existing.id - - -def test_cleanup_orphan_tags_removes_unused(session: Session) -> None: - """Tags not linked to any book should be deleted.""" - user_id = 1 - tag = Tag(user_id=user_id, name="orphan") - session.add(tag) - session.flush() - - cleanup_orphan_tags(session, user_id) - - remaining = session.exec(select(Tag).where(Tag.user_id == user_id)).all() - assert len(remaining) == 0 - - -def test_cleanup_orphan_tags_keeps_linked(session: Session) -> None: - """Tags linked to at least one book should be preserved.""" - user_id = 1 - tag = Tag(user_id=user_id, name="used") - session.add(tag) - book = Book(title="Test", user_id=user_id) - session.add(book) - session.flush() - assert book.id is not None - assert tag.id is not None - session.add(BookTag(book_id=book.id, tag_id=tag.id)) - session.flush() - - cleanup_orphan_tags(session, user_id) - - remaining = session.exec(select(Tag).where(Tag.user_id == user_id)).all() - assert len(remaining) == 1 - - -def test_tags_text_for_book_returns_none_for_no_tags(session: Session) -> None: - """A book with no tags should return None.""" - user_id = 1 - book = Book(title="Test", user_id=user_id) - session.add(book) - session.flush() - assert book.id is not None - - assert tags_text_for_book(session, book.id) is None - - -def test_tags_text_for_book_returns_comma_separated(session: Session) -> None: - """Multiple tags should be returned as a comma-separated string, sorted.""" - user_id = 1 - book = Book(title="Test", user_id=user_id) +def _make_book(session: Session, user_id: int, title: str = "Book", tags: str | None = None) -> Book: + book = Book(title=title, user_id=user_id, reading_status="want_to_read") session.add(book) - session.flush() - assert book.id is not None - for name in ("fantasy", "sci-fi"): - tag = Tag(user_id=user_id, name=name) - session.add(tag) - session.flush() - assert tag.id is not None - session.add(BookTag(book_id=book.id, tag_id=tag.id)) - session.flush() - - result = tags_text_for_book(session, book.id) - assert result == "fantasy, sci-fi" - - -# ── load_tags_batch ─────────────────────────────────────────────────────────── - -def test_load_tags_batch_empty_book_ids(session: Session) -> None: - assert load_tags_batch(session, []) == {} - - -def test_load_tags_batch_multiple_books(session: Session) -> None: - user_id = 1 - book1 = Book(title="Book 1", user_id=user_id) - book2 = Book(title="Book 2", user_id=user_id) - session.add(book1) - session.add(book2) - session.flush() - assert book1.id is not None - assert book2.id is not None - - tag1 = Tag(user_id=user_id, name="fantasy") - tag2 = Tag(user_id=user_id, name="sci-fi") - tag3 = Tag(user_id=user_id, name="history") - session.add(tag1) - session.add(tag2) - session.add(tag3) - session.flush() - assert tag1.id is not None - assert tag2.id is not None - assert tag3.id is not None - - session.add(BookTag(book_id=book1.id, tag_id=tag1.id)) - session.add(BookTag(book_id=book1.id, tag_id=tag2.id)) - session.add(BookTag(book_id=book2.id, tag_id=tag3.id)) - - result = load_tags_batch(session, [book1.id, book2.id]) - assert result[book1.id] == "fantasy, sci-fi" - assert result[book2.id] == "history" + session.commit() + session.refresh(book) + if tags and book.id is not None: + from app.services.tags import sync_book_tags + + sync_book_tags(session, user_id, book.id, tags) + session.commit() + session.refresh(book) + return book + + +class TestParseTags: + def test_basic(self): + assert parse_tags("a, b ,a") == ["a", "b"] + + def test_list_mode(self): + assert parse_tags(["x", " y ", "x"]) == ["x", "y"] + + def test_empty(self): + assert parse_tags(None) == [] + assert parse_tags("") == [] + + +class TestListTagsWithCounts: + def test_counts_and_ordering(self, session: Session): + user = User(firstname="U", lastname="X", email="u@x.com", hashed_password="x") + session.add(user) + session.commit() + session.refresh(user) + assert user.id is not None + b1 = _make_book(session, user.id, "B1", "sci-fi, cars") + b2 = _make_book(session, user.id, "B2", "sci-fi") + rows, total = list_tags_with_counts(session, user.id) + assert total == 2 + assert rows[0] == ("sci-fi", 2) + assert rows[1] == ("cars", 1) + + def test_filter_q(self, session: Session): + user = User(firstname="U", lastname="X", email="u2@x.com", hashed_password="x") + session.add(user) + session.commit() + session.refresh(user) + assert user.id is not None + _make_book(session, user.id, "B1", "alpha, beta") + rows, total = list_tags_with_counts(session, user.id, q="alp") + assert total == 1 + assert rows == [("alpha", 1)] + + +class TestApplyBatchTags: + def test_add_and_remove(self, session: Session): + user = User(firstname="U", lastname="X", email="u3@x.com", hashed_password="x") + session.add(user) + session.commit() + session.refresh(user) + assert user.id is not None + b1 = _make_book(session, user.id, "B1", "cars") + b2 = _make_book(session, user.id, "B2") + assert b1.id is not None and b2.id is not None + result = apply_batch_tags(session, user.id, [b1, b2], ["sci-fi"], ["cars"]) + session.commit() + assert result.books_changed == 2 + assert result.links_added == 2 # both get sci-fi (b1 already? no, new) + assert result.links_removed == 1 # cars removed from b1 + assert "sci-fi" in result.created_tags + assert "cars" in result.deleted_tags + # verify + tags_b1 = session.exec( + select(Tag.name).join(BookTag, col(BookTag.tag_id) == col(Tag.id)).where(BookTag.book_id == b1.id) + ).all() + assert "sci-fi" in tags_b1 and "cars" not in tags_b1 + + def test_idempotent(self, session: Session): + user = User(firstname="U", lastname="X", email="u4@x.com", hashed_password="x") + session.add(user) + session.commit() + session.refresh(user) + assert user.id is not None + b1 = _make_book(session, user.id, "B1", "sci-fi") + assert b1.id is not None + result = apply_batch_tags(session, user.id, [b1], ["sci-fi"], []) + session.commit() + assert result.links_added == 0 + assert result.books_changed == 0 + + +class TestTagEndpoints: + def test_list_tags(self, client): + # create a book with a tag via direct API? use books create + client.post("/api/books", json={"title": "B", "author": "A", "page_count": 10, "tags": "hello"}) + r = client.get("/api/tags") + assert r.status_code == 200 + data = r.json() + assert data["total"] >= 1 + assert any(t["name"] == "hello" for t in data["tags"]) + + def test_batch_add_remove(self, client): + r1 = client.post("/api/books", json={"title": "B1", "author": "A", "page_count": 10, "tags": "cars"}) + r2 = client.post("/api/books", json={"title": "B2", "author": "A", "page_count": 10}) + id1 = r1.json()["id"] + id2 = r2.json()["id"] + r = client.post("/api/tags/batch", json={"book_ids": [id1, id2], "add": ["sci-fi"], "remove": ["cars"]}) + assert r.status_code == 200 + data = r.json() + assert data["books_changed"] == 2 + # verify tags + b1 = client.get(f"/api/books/{id1}").json() + assert "sci-fi" in (b1["tags"] or "") + assert "cars" not in (b1["tags"] or "") + + def test_unknown_book_404(self, client): + r = client.post("/api/tags/batch", json={"book_ids": [999999], "add": ["x"]}) + assert r.status_code == 404 + + def test_empty_ids_422(self, client): + r = client.post("/api/tags/batch", json={"book_ids": [], "add": ["x"]}) + assert r.status_code == 422 + + def test_too_many_422(self, client): + r = client.post("/api/tags/batch", json={"book_ids": list(range(1, 502)), "add": ["x"]}) + assert r.status_code == 422 + + def test_overlap_422(self, client): + r = client.post("/api/books", json={"title": "O", "author": "A", "page_count": 10}) + bid = r.json()["id"] + r2 = client.post("/api/tags/batch", json={"book_ids": [bid], "add": ["X"], "remove": ["x"]}) + assert r2.status_code == 422 + + def test_no_tags_422(self, client): + r = client.post("/api/tags/batch", json={"book_ids": [1], "add": [], "remove": []}) + assert r.status_code == 422 + + def test_ids_endpoint_filters(self, client): + client.post("/api/books", json={"title": "Tagged", "author": "A", "page_count": 10, "tags": "special"}) + client.post("/api/books", json={"title": "Untagged", "author": "A", "page_count": 10}) + r = client.get("/api/books/ids?tag=special") + assert r.status_code == 200 + data = r.json() + assert data["total"] >= 1 + # exclude + r2 = client.get("/api/books/ids?exclude_tag=special") + assert r2.status_code == 200 + + def test_books_list_tag_filter(self, client): + r = client.get("/api/books?tag=special") + assert r.status_code == 200 + assert "books" in r.json() + + +class TestTagOwnership: + def test_foreign_book_not_accessible(self, client, create_user_with_key, session): + other, other_key = create_user_with_key(email="other@x.com") + # create a book as other user directly + other_book = Book(title="OtherBook", user_id=other.id, reading_status="want_to_read") + session.add(other_book) + session.commit() + session.refresh(other_book) + assert other_book.id is not None + r = client.post("/api/tags/batch", json={"book_ids": [other_book.id], "add": ["x"]}) + assert r.status_code == 404 + + +class TestLegacyTagServices: + def test_parse_tags_whitespace_and_dedupe(self): + assert parse_tags(" fantasy , sci fi , fantasy") == ["fantasy", "sci fi"] + + def test_parse_tags_skips_empty(self): + assert parse_tags("a,, ,b") == ["a", "b"] + + def test_parse_tags_case_insensitive_dedupe(self): + assert parse_tags("Foo, foo, FOO") == ["Foo"] + + def test_sync_book_tags_clear_on_none(self, session: Session): + user = User(firstname="U", lastname="X", email="legacy1@x.com", hashed_password="x") + session.add(user) + session.commit() + session.refresh(user) + assert user.id is not None + book = _make_book(session, user.id, "B", "a, b") + assert book.id is not None + from app.services.tags import sync_book_tags + + sync_book_tags(session, user.id, book.id, None) + session.commit() + tags = session.exec( + select(Tag.name).join(BookTag, col(BookTag.tag_id) == col(Tag.id)).where(BookTag.book_id == book.id) + ).all() + assert tags == [] + + def test_sync_book_tags_reuses_existing(self, session: Session): + user = User(firstname="U", lastname="X", email="legacy2@x.com", hashed_password="x") + session.add(user) + session.commit() + session.refresh(user) + assert user.id is not None + b1 = _make_book(session, user.id, "B1", "shared") + b2 = _make_book(session, user.id, "B2") + assert b1.id is not None and b2.id is not None + from app.services.tags import sync_book_tags + + sync_book_tags(session, user.id, b2.id, "shared") + session.commit() + tag_rows = session.exec(select(Tag).where(Tag.user_id == user.id, Tag.name == "shared")).all() + assert len(tag_rows) == 1 + + def test_cleanup_orphan_tags_removes_unused(self, session: Session): + user = User(firstname="U", lastname="X", email="legacy3@x.com", hashed_password="x") + session.add(user) + session.commit() + session.refresh(user) + assert user.id is not None + book = _make_book(session, user.id, "B", "orphan") + assert book.id is not None + from app.services.tags import sync_book_tags + + sync_book_tags(session, user.id, book.id, None) + session.commit() + from app.services.tags import cleanup_orphan_tags + + cleanup_orphan_tags(session, user.id) + session.commit() + remaining = session.exec(select(Tag).where(Tag.user_id == user.id, Tag.name == "orphan")).all() + assert remaining == [] + + def test_load_tags_batch(self, session: Session): + user = User(firstname="U", lastname="X", email="legacy4@x.com", hashed_password="x") + session.add(user) + session.commit() + session.refresh(user) + assert user.id is not None + b1 = _make_book(session, user.id, "B1", "t1, t2") + b2 = _make_book(session, user.id, "B2") + assert b1.id is not None and b2.id is not None + from app.services.tags import load_tags_batch + + result = load_tags_batch(session, [b1.id, b2.id]) + assert result.get(b1.id) == "t1, t2" + assert result.get(b2.id) is None + + def test_tags_text_for_book(self, session: Session): + user = User(firstname="U", lastname="X", email="legacy5@x.com", hashed_password="x") + session.add(user) + session.commit() + session.refresh(user) + assert user.id is not None + book = _make_book(session, user.id, "B", "zeta, alpha") + assert book.id is not None + from app.services.tags import tags_text_for_book + + text = tags_text_for_book(session, book.id) + assert text == "alpha, zeta" \ No newline at end of file From 5a9172f7ac0a190ec3f7daa4be803ee9e33625da Mon Sep 17 00:00:00 2001 From: codebude Date: Thu, 29 Oct 2026 13:48:33 +0100 Subject: [PATCH 2/3] Add Mass Tag Editor page and wire it into the profile and navigation. --- frontend/src/lib/api.ts | 47 ++ frontend/src/lib/i18n/locales/de.json | 57 +- frontend/src/lib/i18n/locales/en.json | 55 +- frontend/src/lib/i18n/locales/es.json | 57 +- frontend/src/lib/i18n/locales/fr.json | 57 +- frontend/src/lib/i18n/locales/zh.json | 57 +- frontend/src/lib/types.ts | 31 + frontend/src/lib/utils/tagBatch.test.ts | 37 + frontend/src/lib/utils/tagBatch.ts | 40 ++ frontend/src/routes/+layout.svelte | 4 + frontend/src/routes/data-hygiene/+page.svelte | 6 + frontend/src/routes/profile/+page.svelte | 6 +- frontend/src/routes/tag-editor/+page.svelte | 639 ++++++++++++++++++ 13 files changed, 1083 insertions(+), 10 deletions(-) create mode 100644 frontend/src/lib/utils/tagBatch.test.ts create mode 100644 frontend/src/lib/utils/tagBatch.ts create mode 100644 frontend/src/routes/tag-editor/+page.svelte diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 69664b09..b86b55cd 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,6 +1,7 @@ import type { ApiKeyMeta, AppConfig, + BookIdListResponse, BookListResponse, DataExportDataset, DataExportFormat, @@ -36,7 +37,10 @@ import type { ReadingStatus, AcquisitionStatus, SearchStage, + TagBatchRequest, + TagBatchResponse, TagCloudEntry, + TagListResponse, SortField, SortOrder, OidcConfig, @@ -433,6 +437,8 @@ export const api = { medium?: Medium; q?: string; has_cover?: boolean; + tag?: string; + exclude_tag?: string; sort?: SortField; order?: SortOrder; smart_sort?: boolean; @@ -445,6 +451,8 @@ export const api = { if (params?.medium) qs.set('medium', params.medium); if (params?.q) qs.set('q', params.q); if (params?.has_cover !== undefined) qs.set('has_cover', String(params.has_cover)); + if (params?.tag) qs.set('tag', params.tag); + if (params?.exclude_tag) qs.set('exclude_tag', params.exclude_tag); if (params?.sort) qs.set('sort', params.sort); if (params?.order) qs.set('order', params.order); if (params?.smart_sort !== undefined) qs.set('smart_sort', String(params.smart_sort)); @@ -458,6 +466,27 @@ export const api = { return request(`/books/${id}`); }, + ids(params?: { + status?: ReadingStatus; + acquisition_status?: AcquisitionStatus; + medium?: Medium; + q?: string; + has_cover?: boolean; + tag?: string; + exclude_tag?: string; + }): Promise { + const qs = new URLSearchParams(); + if (params?.status) qs.set('status', params.status); + if (params?.acquisition_status) qs.set('acquisition_status', params.acquisition_status); + if (params?.medium) qs.set('medium', params.medium); + if (params?.q) qs.set('q', params.q); + if (params?.has_cover !== undefined) qs.set('has_cover', String(params.has_cover)); + if (params?.tag) qs.set('tag', params.tag); + if (params?.exclude_tag) qs.set('exclude_tag', params.exclude_tag); + const query = qs.toString() ? `?${qs}` : ''; + return request(`/books/ids${query}`); + }, + create(data: Partial): Promise { return request('/books', { method: 'POST', body: JSON.stringify(data) }); }, @@ -535,6 +564,24 @@ export const api = { } }, + tags: { + async list(params?: { q?: string; limit?: number; offset?: number }): Promise { + const qs = new URLSearchParams(); + if (params?.q) qs.set('q', params.q); + if (params?.limit !== undefined) qs.set('limit', String(params.limit)); + if (params?.offset !== undefined) qs.set('offset', String(params.offset)); + const query = qs.toString() ? `?${qs}` : ''; + return request(`/tags${query}`); + }, + + async batch(data: TagBatchRequest): Promise { + return request('/tags/batch', { + method: 'POST', + body: JSON.stringify(data) + }); + } + }, + hygiene: { async listMissing(params: { attributes: HygieneAttribute[]; diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 0ff8555a..8a0d5a0a 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -605,7 +605,9 @@ "description": "Exportiere deine Bibliothek oder importiere Bücher aus CSV, JSON oder Excel.", "link": "Datenseite öffnen", "missingCoversDescription": "Fehlende Cover mit automatischen Vorschlägen schnell zuweisen.", - "missingCoversLink": "Fehlende Cover verwalten" + "missingCoversLink": "Fehlende Cover verwalten", + "tagEditorDescription": "Tags bei vielen Büchern auf einmal hinzufügen oder entfernen.", + "tagEditorLink": "Massen-Tag-Editor" }, "dangerZone": { "title": "Gefahrenbereich", @@ -888,7 +890,8 @@ "noCover": "Kein Cover", "loadBookDetailsFailed": "Buchdetails konnten nicht geladen werden.", "remaining": "übrig", - "andXMore": "...und {count} weitere" + "andXMore": "...und {count} weitere", + "bulkTagsHint": "Möchten Sie Tags bei vielen Büchern hinzufügen oder entfernen?" }, "about": { "title": "Über LibrisLog", @@ -928,5 +931,55 @@ "keyboardHint": "Tipp: Drücke 1–9, um ein Cover auszuwählen, → zum Überspringen", "candidatesError": "Cover-Suche fehlgeschlagen. Du kannst weiterhin den manuellen Import verwenden.", "retry": "Wiederholen" + }, + "tagEditor": { + "title": "Massen-Tag-Editor", + "description": "Filtern und durchsuchen Sie Ihre Bibliothek, wählen Sie ein oder mehrere Bücher aus und fügen Sie Tags hinzu oder entfernen Sie sie in einem Schritt.", + "sectionFilters": "Bücher filtern", + "statusFilter": "Lesestatus", + "mediumFilter": "Medium", + "acquisitionFilter": "Erwerbsstatus", + "tagFilter": "Tag", + "allStatuses": "Alle Status", + "allMedia": "Alle Medien", + "allAcquisition": "Alle Erwerbsstatus", + "allTags": "Alle Tags", + "tagHas": "Hat Tag", + "tagLacks": "Ohne Tag", + "resetFilters": "Filter zurücksetzen", + "loading": "Bücher werden geladen...", + "noBooks": "Keine Bücher entsprechen den aktuellen Filtern.", + "loadFailed": "Daten konnten nicht geladen werden.", + "loadMore": "Mehr laden", + "showingCount": "Zeige {shown} von {total}", + "selectAllLoaded": "Alle geladenen auswählen", + "selectAllMatching": "Alle {count} passenden Bücher auswählen", + "allMatchingSelected": "Alle {count} passenden Bücher ausgewählt", + "clearSelection": "Auswahl aufheben", + "nSelected": "{count} Buch/Bücher ausgewählt", + "moreAvailable": "Weitere Bücher passen. Nutzen Sie \"Alle auswählen\", um sie einzubeziehen.", + "noSelection": "Wählen Sie zuerst mindestens ein Buch aus.", + "noTagsEntered": "Geben Sie mindestens einen Tag zum Hinzufügen oder Entfernen ein.", + "overlapError": "Ein Tag kann nicht gleichzeitig hinzugefügt und entfernt werden.", + "addPlaceholder": "Tags hinzufügen...", + "removePlaceholder": "Tags entfernen...", + "caseVariant": "\"{name}\" unterscheidet sich nur in der Groß-/Kleinschreibung vom vorhandenen \"{existing}\".", + "useExisting": "Vorhandene Schreibweise verwenden", + "caseVariantWarning": "Einige Tags unterscheiden sich nur in der Groß-/Kleinschreibung von vorhandenen Tags.", + "apply": "Auf Auswahl anwenden", + "confirmTitle": "Tags für {count} Buch/Bücher aktualisieren?", + "confirmBody": "Hinzufügen: {add}. Entfernen: {remove}.", + "confirmAllMatching": "Alle {count} Bücher, die den aktuellen Filtern entsprechen, werden aktualisiert.", + "confirmCancel": "Abbrechen", + "confirmApply": "Änderungen anwenden", + "andXMore": "und {count} weitere", + "progress": "Stapel {current} von {total} wird angewendet...", + "changed": "{count} Buch/Bücher geändert", + "linksAdded": "{count} Tag-Verknüpfung(en) hinzugefügt", + "linksRemoved": "{count} Tag-Verknüpfung(en) entfernt", + "deletedTags": "Ungenutzte Tags gelöscht: {tags}", + "noChanges": "Keine Änderungen nötig.", + "updateFailed": "Tag-Aktualisierung fehlgeschlagen.", + "updateFailedPartial": "Tag update failed after {completed} of {total} batches. Already applied changes are kept; retry to finish." } } diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 38b59c0c..da559412 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -605,7 +605,9 @@ "description": "Export your library or import books from a CSV, JSON, or Excel file.", "link": "Import / Export", "missingCoversDescription": "Quickly assign missing covers with auto-suggestions.", - "missingCoversLink": "Manage Missing Covers" + "missingCoversLink": "Manage Missing Covers", + "tagEditorDescription": "Add or remove tags on many books at once.", + "tagEditorLink": "Mass Tag Editor" }, "dangerZone": { "title": "Danger Zone", @@ -871,6 +873,7 @@ "updateFailed": "Batch update failed.", "loadFailed": "Failed to load data.", "tooManySelected": "Please select at most 500 books at a time.", + "bulkTagsHint": "Need to add or remove tags on many books?", "noAttributeSelected": "Select at least one attribute to search for.", "noFieldSelected": "Select a field to update.", "noValueEntered": "Enter a value to set.", @@ -928,5 +931,55 @@ "keyboardHint": "Tip: Press 1–9 to select a cover, → to skip", "candidatesError": "Cover search failed. You can still use manual import.", "retry": "Retry" + }, + "tagEditor": { + "title": "Mass Tag Editor", + "description": "Filter and search your library, select one to many books, and add or remove tags in one step.", + "sectionFilters": "Filter books", + "statusFilter": "Reading status", + "mediumFilter": "Medium", + "acquisitionFilter": "Acquisition status", + "tagFilter": "Tag", + "allStatuses": "All statuses", + "allMedia": "All media", + "allAcquisition": "All acquisition statuses", + "allTags": "All tags", + "tagHas": "Has tag", + "tagLacks": "Lacks tag", + "resetFilters": "Reset filters", + "loading": "Loading books...", + "noBooks": "No books match the current filters.", + "loadFailed": "Failed to load data.", + "loadMore": "Load more", + "showingCount": "Showing {shown} of {total}", + "selectAllLoaded": "Select all loaded", + "selectAllMatching": "Select all {count} books matching these filters", + "allMatchingSelected": "All {count} matching books selected", + "clearSelection": "Clear selection", + "nSelected": "{count} book(s) selected", + "moreAvailable": "More books match these filters. Use \"Select all\" to include them.", + "noSelection": "Select at least one book first.", + "noTagsEntered": "Enter at least one tag to add or remove.", + "overlapError": "A tag cannot be both added and removed.", + "addPlaceholder": "Add tags...", + "removePlaceholder": "Remove tags...", + "caseVariant": "\"{name}\" differs only in capitalization from existing \"{existing}\".", + "useExisting": "Use existing spelling", + "caseVariantWarning": "Some tags differ only in capitalization from existing tags.", + "apply": "Apply to selected", + "confirmTitle": "Update tags for {count} book(s)?", + "confirmBody": "Add: {add}. Remove: {remove}.", + "confirmAllMatching": "All {count} books matching the current filters will be updated.", + "confirmCancel": "Cancel", + "confirmApply": "Apply changes", + "andXMore": "and {count} more", + "progress": "Applying batch {current} of {total}...", + "changed": "Changed {count} book(s)", + "linksAdded": "added {count} tag link(s)", + "linksRemoved": "removed {count} tag link(s)", + "deletedTags": "deleted unused tag(s): {tags}", + "noChanges": "No changes needed.", + "updateFailed": "Tag update failed.", + "updateFailedPartial": "Tag update failed after {completed} of {total} batches. Already applied changes are kept; retry to finish." } } diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index cb0bc044..ada389f1 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -605,7 +605,9 @@ "description": "Exporta tu biblioteca o importa libros desde un archivo CSV, JSON o Excel.", "link": "Importar / Exportar", "missingCoversDescription": "Asigna rápidamente portadas faltantes con sugerencias automáticas.", - "missingCoversLink": "Gestionar portadas faltantes" + "missingCoversLink": "Gestionar portadas faltantes", + "tagEditorDescription": "Add or remove tags on many books at once.", + "tagEditorLink": "Mass Tag Editor" }, "dangerZone": { "title": "Zona de peligro", @@ -888,7 +890,8 @@ "noCover": "Sin portada", "loadBookDetailsFailed": "No se pudieron cargar los detalles del libro.", "remaining": "restantes", - "andXMore": "...y {count} más" + "andXMore": "...y {count} más", + "bulkTagsHint": "¿Necesita añadir o quitar etiquetas en muchos libros?" }, "about": { "title": "Acerca de LibrisLog", @@ -928,5 +931,55 @@ "keyboardHint": "Consejo: pulsa 1–9 para seleccionar una portada, → para saltar", "candidatesError": "Búsqueda de portadas fallida. Aún puedes usar la importación manual.", "retry": "Reintentar" + }, + "tagEditor": { + "title": "Editor masivo de etiquetas", + "description": "Filtra y busca en tu biblioteca, selecciona uno o varios libros y añade o quita etiquetas en un solo paso.", + "sectionFilters": "Filtrar libros", + "statusFilter": "Estado de lectura", + "mediumFilter": "Medio", + "acquisitionFilter": "Estado de adquisición", + "tagFilter": "Etiqueta", + "allStatuses": "Todos los estados", + "allMedia": "Todos los medios", + "allAcquisition": "Todos los estados de adquisición", + "allTags": "Todas las etiquetas", + "tagHas": "Tiene etiqueta", + "tagLacks": "Sin etiqueta", + "resetFilters": "Restablecer filtros", + "loading": "Cargando libros...", + "noBooks": "Ningún libro coincide con los filtros actuales.", + "loadFailed": "No se pudieron cargar los datos.", + "loadMore": "Cargar más", + "showingCount": "Mostrando {shown} de {total}", + "selectAllLoaded": "Seleccionar todos los cargados", + "selectAllMatching": "Seleccionar los {count} libros que coinciden", + "allMatchingSelected": "Los {count} libros coincidentes seleccionados", + "clearSelection": "Limpiar selección", + "nSelected": "{count} libro(s) seleccionado(s)", + "moreAvailable": "Hay más libros que coinciden. Usa \"Seleccionar todos\" para incluirlos.", + "noSelection": "Selecciona al menos un libro primero.", + "noTagsEntered": "Introduce al menos una etiqueta para añadir o quitar.", + "overlapError": "Una etiqueta no puede añadirse y quitarse a la vez.", + "addPlaceholder": "Añadir etiquetas...", + "removePlaceholder": "Quitar etiquetas...", + "caseVariant": "\"{name}\" solo difiere en mayúsculas/minúsculas de la etiqueta existente \"{existing}\".", + "useExisting": "Usar la grafía existente", + "caseVariantWarning": "Algunas etiquetas solo difieren en mayúsculas/minúsculas de etiquetas existentes.", + "apply": "Aplicar a la selección", + "confirmTitle": "¿Actualizar etiquetas de {count} libro(s)?", + "confirmBody": "Añadir: {add}. Quitar: {remove}.", + "confirmAllMatching": "Se actualizarán los {count} libros que coinciden con los filtros actuales.", + "confirmCancel": "Cancelar", + "confirmApply": "Aplicar cambios", + "andXMore": "y {count} más", + "progress": "Aplicando lote {current} de {total}...", + "changed": "{count} libro(s) cambiado(s)", + "linksAdded": "se añadieron {count} enlace(s) de etiqueta", + "linksRemoved": "se quitaron {count} enlace(s) de etiqueta", + "deletedTags": "etiqueta(s) sin uso eliminada(s): {tags}", + "noChanges": "No se necesitaron cambios.", + "updateFailed": "Falló la actualización de etiquetas.", + "updateFailedPartial": "Tag update failed after {completed} of {total} batches. Already applied changes are kept; retry to finish." } } diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json index 4caf232e..890e3ac5 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -605,7 +605,9 @@ "description": "Exporte ta bibliothèque ou importe des livres depuis un fichier CSV, JSON ou Excel.", "link": "Importer / Exporter", "missingCoversDescription": "Attribue rapidement les couvertures manquantes avec des suggestions automatiques.", - "missingCoversLink": "Gérer les couvertures manquantes" + "missingCoversLink": "Gérer les couvertures manquantes", + "tagEditorDescription": "Add or remove tags on many books at once.", + "tagEditorLink": "Mass Tag Editor" }, "dangerZone": { "title": "Zone de danger", @@ -888,7 +890,8 @@ "noCover": "Pas de couverture", "loadBookDetailsFailed": "Impossible de charger les détails du livre.", "remaining": "restants", - "andXMore": "...et {count} autres" + "andXMore": "...et {count} autres", + "bulkTagsHint": "Besoin d'ajouter ou de retirer des étiquettes sur de nombreux livres ?" }, "about": { "title": "À propos de LibrisLog", @@ -928,5 +931,55 @@ "keyboardHint": "Astuce : appuie sur 1–9 pour sélectionner une couverture, → pour passer", "candidatesError": "Recherche de couverture échouée. Tu peux toujours utiliser l'importation manuelle.", "retry": "Réessayer" + }, + "tagEditor": { + "title": "Éditeur d'étiquettes en masse", + "description": "Filtrez et recherchez dans votre bibliothèque, sélectionnez un ou plusieurs livres et ajoutez ou retirez des étiquettes en une seule étape.", + "sectionFilters": "Filtrer les livres", + "statusFilter": "Statut de lecture", + "mediumFilter": "Support", + "acquisitionFilter": "Statut d'acquisition", + "tagFilter": "Étiquette", + "allStatuses": "Tous les statuts", + "allMedia": "Tous les supports", + "allAcquisition": "Tous les statuts d'acquisition", + "allTags": "Toutes les étiquettes", + "tagHas": "A l'étiquette", + "tagLacks": "Sans étiquette", + "resetFilters": "Réinitialiser les filtres", + "loading": "Chargement des livres...", + "noBooks": "Aucun livre ne correspond aux filtres actuels.", + "loadFailed": "Échec du chargement des données.", + "loadMore": "Charger plus", + "showingCount": "Affichage de {shown} sur {total}", + "selectAllLoaded": "Sélectionner tous les chargés", + "selectAllMatching": "Sélectionner les {count} livres correspondants", + "allMatchingSelected": "Les {count} livres correspondants sélectionnés", + "clearSelection": "Effacer la sélection", + "nSelected": "{count} livre(s) sélectionné(s)", + "moreAvailable": "D'autres livres correspondent. Utilisez « Tout sélectionner » pour les inclure.", + "noSelection": "Sélectionnez d'abord au moins un livre.", + "noTagsEntered": "Saisissez au moins une étiquette à ajouter ou à retirer.", + "overlapError": "Une étiquette ne peut pas être ajoutée et retirée en même temps.", + "addPlaceholder": "Ajouter des étiquettes...", + "removePlaceholder": "Retirer des étiquettes...", + "caseVariant": "\"{name}\" ne diffère que par la casse de l'étiquette existante \"{existing}\".", + "useExisting": "Utiliser la graphie existante", + "caseVariantWarning": "Certaines étiquettes ne diffèrent que par la casse d'étiquettes existantes.", + "apply": "Appliquer à la sélection", + "confirmTitle": "Mettre à jour les étiquettes de {count} livre(s) ?", + "confirmBody": "Ajouter : {add}. Retirer : {remove}.", + "confirmAllMatching": "Les {count} livres correspondant aux filtres actuels seront mis à jour.", + "confirmCancel": "Annuler", + "confirmApply": "Appliquer les modifications", + "andXMore": "et {count} de plus", + "progress": "Application du lot {current} sur {total}...", + "changed": "{count} livre(s) modifié(s)", + "linksAdded": "{count} lien(s) d'étiquette ajouté(s)", + "linksRemoved": "{count} lien(s) d'étiquette retiré(s)", + "deletedTags": "étiquette(s) inutilisée(s) supprimée(s) : {tags}", + "noChanges": "Aucune modification nécessaire.", + "updateFailed": "Échec de la mise à jour des étiquettes.", + "updateFailedPartial": "Tag update failed after {completed} of {total} batches. Already applied changes are kept; retry to finish." } } diff --git a/frontend/src/lib/i18n/locales/zh.json b/frontend/src/lib/i18n/locales/zh.json index ad7d0c98..2691648e 100644 --- a/frontend/src/lib/i18n/locales/zh.json +++ b/frontend/src/lib/i18n/locales/zh.json @@ -605,7 +605,9 @@ "description": "导出你的书库,或从 CSV、JSON 或 Excel 文件导入图书。", "link": "导入 / 导出", "missingCoversDescription": "使用自动建议快速分配缺失封面。", - "missingCoversLink": "管理缺失封面" + "missingCoversLink": "管理缺失封面", + "tagEditorDescription": "Add or remove tags on many books at once.", + "tagEditorLink": "Mass Tag Editor" }, "dangerZone": { "title": "危险区域", @@ -888,7 +890,8 @@ "noCover": "无封面", "loadBookDetailsFailed": "加载图书详情失败。", "remaining": "剩余", - "andXMore": "...还有 {count} 个" + "andXMore": "...还有 {count} 个", + "bulkTagsHint": "需要在许多书籍上添加或移除标签吗?" }, "about": { "title": "关于 LibrisLog", @@ -928,5 +931,55 @@ "keyboardHint": "提示:按 1–9 选择封面,按 → 跳过", "candidatesError": "封面搜索失败。你仍可使用手动导入。", "retry": "重试" + }, + "tagEditor": { + "title": "批量标签编辑器", + "description": "筛选并搜索您的图书馆,选择一个或多个书籍,一步完成添加或移除标签。", + "sectionFilters": "筛选书籍", + "statusFilter": "阅读状态", + "mediumFilter": "媒介", + "acquisitionFilter": "购置状态", + "tagFilter": "标签", + "allStatuses": "所有状态", + "allMedia": "所有媒介", + "allAcquisition": "所有购置状态", + "allTags": "所有标签", + "tagHas": "包含标签", + "tagLacks": "不含标签", + "resetFilters": "重置筛选", + "loading": "正在加载书籍...", + "noBooks": "没有书籍符合当前筛选条件。", + "loadFailed": "数据加载失败。", + "loadMore": "加载更多", + "showingCount": "显示 {shown} / {total}", + "selectAllLoaded": "选择所有已加载", + "selectAllMatching": "选择所有 {count} 本匹配书籍", + "allMatchingSelected": "已选择全部 {count} 本匹配书籍", + "clearSelection": "清除选择", + "nSelected": "已选择 {count} 本书", + "moreAvailable": "还有更多匹配的书籍。使用“全选”将其包含。", + "noSelection": "请先至少选择一本书。", + "noTagsEntered": "请输入至少一个要添加或移除的标签。", + "overlapError": "一个标签不能同时被添加和移除。", + "addPlaceholder": "添加标签...", + "removePlaceholder": "移除标签...", + "caseVariant": "“{name}”仅在大小写上与现有标签“{existing}”不同。", + "useExisting": "使用现有拼写", + "caseVariantWarning": "部分标签仅在大小写上与现有标签不同。", + "apply": "应用到所选", + "confirmTitle": "更新 {count} 本书的标签?", + "confirmBody": "添加:{add}。移除:{remove}。", + "confirmAllMatching": "将更新所有 {count} 本符合当前筛选的书籍。", + "confirmCancel": "取消", + "confirmApply": "应用更改", + "andXMore": "还有 {count} 本", + "progress": "正在应用第 {current} 批,共 {total} 批...", + "changed": "已更改 {count} 本书", + "linksAdded": "已添加 {count} 个标签链接", + "linksRemoved": "已移除 {count} 个标签链接", + "deletedTags": "已删除未使用的标签:{tags}", + "noChanges": "无需更改。", + "updateFailed": "标签更新失败。", + "updateFailedPartial": "Tag update failed after {completed} of {total} batches. Already applied changes are kept; retry to finish." } } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 5fd459f5..acbe9b85 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -531,6 +531,37 @@ export interface HygieneBatchUpdateResponse { skipped_ids: number[]; } +export interface TagListEntry { + name: string; + count: number; +} + +export interface TagListResponse { + tags: TagListEntry[]; + total: number; +} + +export interface TagBatchRequest { + book_ids: number[]; + add?: string[]; + remove?: string[]; +} + +export interface TagBatchResponse { + matched_books: number; + books_changed: number; + links_added: number; + links_removed: number; + unchanged_books: number; + created_tags: string[]; + deleted_tags: string[]; +} + +export interface BookIdListResponse { + ids: number[]; + total: number; +} + export type PublicProfileAudience = 'public' | 'authenticated'; export type PublicProfileSectionKey = diff --git a/frontend/src/lib/utils/tagBatch.test.ts b/frontend/src/lib/utils/tagBatch.test.ts new file mode 100644 index 00000000..57ad6d4e --- /dev/null +++ b/frontend/src/lib/utils/tagBatch.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { chunk, findCaseVariants, mergeTagSuggestions } from './tagBatch'; + +describe('chunk', () => { + it('splits into chunks of given size', () => { + expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); + }); + it('returns single chunk when smaller', () => { + expect(chunk([1, 2], 5)).toEqual([[1, 2]]); + }); + it('returns empty for empty input', () => { + expect(chunk([], 3)).toEqual([]); + }); +}); + +describe('findCaseVariants', () => { + it('detects case-only differences', () => { + const res = findCaseVariants(['sci-fi'], ['Sci-Fi']); + expect(res).toEqual([{ name: 'sci-fi', existing: 'Sci-Fi' }]); + }); + it('ignores exact matches', () => { + expect(findCaseVariants(['Sci-Fi'], ['Sci-Fi'])).toEqual([]); + }); + it('ignores unrelated names', () => { + expect(findCaseVariants(['foo'], ['Bar'])).toEqual([]); + }); + it('deduplicates reported variants', () => { + const res = findCaseVariants(['a', 'A'], ['A']); + expect(res.length).toBeLessThanOrEqual(1); + }); +}); + +describe('mergeTagSuggestions', () => { + it('merges and dedupes case-insensitively', () => { + expect(mergeTagSuggestions(['Foo', 'bar'], ['foo', 'baz'])).toEqual(['Foo', 'bar', 'baz']); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/utils/tagBatch.ts b/frontend/src/lib/utils/tagBatch.ts new file mode 100644 index 00000000..a19f5bcb --- /dev/null +++ b/frontend/src/lib/utils/tagBatch.ts @@ -0,0 +1,40 @@ +export function chunk(items: T[], size: number): T[][] { + if (size <= 0) throw new Error('chunk size must be positive'); + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) { + out.push(items.slice(i, i + size)); + } + return out; +} + +export interface CaseVariant { + name: string; + existing: string; +} + +export function findCaseVariants(names: string[], existingNames: string[]): CaseVariant[] { + const seen = new Map(existingNames.map((n) => [n.toLowerCase(), n])); + const result: CaseVariant[] = []; + const reported = new Set(); + for (const name of names) { + const key = name.toLowerCase(); + const existing = seen.get(key); + if (existing && existing !== name && !reported.has(key)) { + result.push({ name, existing }); + reported.add(key); + } + } + return result; +} + +export function mergeTagSuggestions(primary: string[], secondary: string[]): string[] { + const out: string[] = []; + const seen = new Set(); + for (const name of [...primary, ...secondary]) { + const key = name.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(name); + } + return out; +} \ No newline at end of file diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index e6d52004..4491bc81 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -229,6 +229,10 @@ return `${$_('app.title')} - ${$_('dataHygiene.title')}`; } + if (page.url.pathname.startsWith('/tag-editor')) { + return `${$_('app.title')} - ${$_('tagEditor.title')}`; + } + if (page.url.pathname.startsWith('/admin')) { return `${$_('app.title')} - ${$_('admin.title')}`; } diff --git a/frontend/src/routes/data-hygiene/+page.svelte b/frontend/src/routes/data-hygiene/+page.svelte index d7ed1136..8645d8f8 100644 --- a/frontend/src/routes/data-hygiene/+page.svelte +++ b/frontend/src/routes/data-hygiene/+page.svelte @@ -504,6 +504,12 @@ {/if} +
+
+ {$_('dataHygiene.bulkTagsHint')} {$_('profile.dataManagement.tagEditorLink')} +
+
+ {#if coverViewer}
diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index 12ae58a9..809bc5c0 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -1229,7 +1229,7 @@ import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte';

{$_('profile.dataManagement.title')}

-
+

{$_('profile.dataManagement.description')}

{$_('profile.dataManagement.link')} @@ -1242,6 +1242,10 @@ import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte';

{$_('profile.dataManagement.missingCoversDescription')}

{$_('profile.dataManagement.missingCoversLink')}
+
+

{$_('profile.dataManagement.tagEditorDescription')}

+ {$_('profile.dataManagement.tagEditorLink')} +
diff --git a/frontend/src/routes/tag-editor/+page.svelte b/frontend/src/routes/tag-editor/+page.svelte new file mode 100644 index 00000000..9d73238c --- /dev/null +++ b/frontend/src/routes/tag-editor/+page.svelte @@ -0,0 +1,639 @@ + + +
+
+
+
+

{$_('tagEditor.title')}

+

{$_('tagEditor.description')}

+
+
+
+ + +
+
+

{$_('tagEditor.sectionFilters')}

+
+ { searchQuery = q; void loadData(true); }} /> +
+ + + + + + + + + {#if tagFilter} +
+ + +
+ {/if} + + +
+
+
+
+ + {#if loading && books.length === 0} +
+
+ +

{$_('tagEditor.loading')}

+
+
+ {:else if error && books.length === 0} + (error = null)}>{error} + {:else if !loading && books.length === 0} +
+
+

{$_('tagEditor.noBooks')}

+
+
+ {:else} +
+
+ + + + + + + + + + + + {#each books as book (book.id)} + + + + + + + + {/each} + +
+ + {$_('book.title')}
+ toggleBook(book.id)} + aria-label={book.title} + /> + + +
+
+ {#if hasMore} +
+ + {$_('tagEditor.showingCount', { values: { shown: books.length, total } })} + + +
+ {/if} +
+ + {#if total > selectedCount && selectedCount > 0 && allMatchingIds === null} +
+ {$_('tagEditor.moreAvailable', { values: { total } })} +
+ {/if} + {/if} + + + {#if total > PAGE_SIZE && !loading} +
+ {#if allMatchingIds === null} + + {:else} + + {$_('tagEditor.allMatchingSelected', { values: { count: allMatchingIds.length } })} + + + {/if} +
+ {/if} +
+ + +{#if selectedCount > 0} +
+
+ + {$_('tagEditor.nSelected', { values: { count: selectedCount } })} + + +
+ api.books.suggestions.tags(q)} + /> + {#if caseVariants.length > 0} +
+ {#each caseVariants as v (v.name)} +
+ {$_('tagEditor.caseVariant', { values: { name: v.name, existing: v.existing } })} + +
+ {/each} +
+ {/if} +
+ +
+ + Promise.resolve( + availableTags + .map((t) => t.name) + .filter((n) => n.toLowerCase().includes(q.toLowerCase())) + )} + /> +
+ + {#if overlapTags.length > 0} +
+ {$_('tagEditor.overlapError')} +
+ {/if} + + +
+
+{/if} + + + + + + + + + \ No newline at end of file From b6c14312c9e72ddfc4b130affcb684e24fdce79f Mon Sep 17 00:00:00 2001 From: codebude Date: Thu, 29 Oct 2026 13:48:34 +0100 Subject: [PATCH 3/3] Document the Mass Tag Editor and add the v1.12.0 release entry. --- README.md | 1 + docs/.vitepress/config.base.ts | 1 + docs/guide/using-librislog/data-hygiene.md | 1 + docs/guide/using-librislog/mass-tag-editor.md | 58 +++++++++++++++++++ docs/guide/using-librislog/profile.md | 3 +- docs/releases.md | 29 ++++++++++ 6 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 docs/guide/using-librislog/mass-tag-editor.md diff --git a/README.md b/README.md index c9f0be61..83dc8c8e 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ Open **http://localhost:8001** and create your account. - **Data portability** — Export as JSON, CSV, or ZIP with covers. Import from the Goodreads or Bookstats presets, or any CSV/Excel file with custom field mapping - **Cover management** — Automatic multi-source cover search with manual override, URL paste, or file upload - **Data hygiene** — Find and fix missing metadata (covers, page counts, authors) in bulk +- **Mass tag editor** — Add or remove tags on many books at once - **Multi-user** — Admin/user roles, per-user libraries, optional OIDC login - **Public profiles** — Shareable, read-only profile links with configurable sections, audience, language, and optional expiry - **Themes** — Light, dark, and custom DaisyUI themes with persistent preferences diff --git a/docs/.vitepress/config.base.ts b/docs/.vitepress/config.base.ts index 85da19ba..e147e0d4 100644 --- a/docs/.vitepress/config.base.ts +++ b/docs/.vitepress/config.base.ts @@ -76,6 +76,7 @@ export default defineConfig({ { text: 'Statistics', link: '/guide/using-librislog/statistics' }, { text: 'Import & Export', link: '/guide/using-librislog/import-export' }, { text: 'Data Hygiene', link: '/guide/using-librislog/data-hygiene' }, + { text: 'Mass Tag Editor', link: '/guide/using-librislog/mass-tag-editor' }, { text: 'Missing Covers', link: '/guide/using-librislog/missing-covers' }, { text: 'Administration', link: '/guide/using-librislog/administration' }, ], diff --git a/docs/guide/using-librislog/data-hygiene.md b/docs/guide/using-librislog/data-hygiene.md index 44b9ef30..572c4c32 100644 --- a/docs/guide/using-librislog/data-hygiene.md +++ b/docs/guide/using-librislog/data-hygiene.md @@ -51,4 +51,5 @@ No lower bound is enforced, so ancient or religious texts (e.g., the Bible) can ## See Also +- [Mass Tag Editor](/guide/using-librislog/mass-tag-editor) — add or remove tags on many books at once - [Missing Covers Workflow](/guide/using-librislog/missing-covers) — dedicated one-at-a-time workflow for assigning book covers diff --git a/docs/guide/using-librislog/mass-tag-editor.md b/docs/guide/using-librislog/mass-tag-editor.md new file mode 100644 index 00000000..71eb5fb9 --- /dev/null +++ b/docs/guide/using-librislog/mass-tag-editor.md @@ -0,0 +1,58 @@ +# Mass Tag Editor + +The Mass Tag Editor lets you add or remove tags on many books at once. It is accessible from **Profile → Manage my data → Mass Tag Editor**. + +Tags are the recommended way to group books (for example a shelf, a series, or a reading project). This page makes applying them to a large library practical. + +## Filtering books + +Use the filter bar to narrow the list: + +- **Search** — full text search, including field prefixes such as `tag:cars` or `author:"Jane Driver"` and negation with `-`. +- **Reading status**, **Medium**, **Acquisition status** — standard library filters. +- **Tag** — show only books that have (or lack) a specific tag, using the **Has tag** / **Lacks tag** toggle. + +The table shows the title, author, current tags, and reading status of each book. Click a title to open it in the library. + +## Selecting books + +- Check individual rows, or use the header checkbox to select all books on the current page. +- **Load more** appends the next page without losing your selection. +- **Select all N matching** (shown when more than one page exists) selects every book that matches the current filters, even those not yet loaded. The action bar then shows the total count. +- **Clear selection** leaves the all-matching mode. + +Up to 500 books are processed per request; larger selections are split automatically and progress is shown. + +## Adding and removing tags + +The action bar at the bottom of the screen has two fields: + +- **Add tags** — tags to apply to every selected book. +- **Remove tags** — tags to remove from every selected book. + +A tag cannot be both added and removed in the same operation. Suggestions come from your existing tags. + +### Case differences + +Tag matching is exact, including capitalization. If you type a tag that differs only in capitalization from an existing tag (for example `sci-fi` next to `Sci-Fi`), a warning appears with a one-click shortcut to reuse the existing spelling. The warning is advisory; the tag you typed is still created if you proceed. + +## Confirmation and result + +Click **Apply to selected** to open a confirmation dialog showing the affected count, the tags to add and remove, and a preview of the first ten affected titles (or a summary when all matching books are selected). Confirm to apply. + +After a successful run, a summary toast reports how many books changed, how many tag links were added and removed, and which tags were deleted because they no longer had any book. + +## Orphan tag cleanup + +When a tag ends up on no book after a removal, it is deleted automatically, the same as when editing a single book or deleting one. + +## Limits + +- At most 500 books per request; larger selections are chunked automatically. +- At most 50 tags per add or remove list. +- Tag names must not contain commas. + +## See also + +- [Data Hygiene](/guide/using-librislog/data-hygiene) — bulk-fix missing metadata. +- [Library](/guide/using-librislog/library) — search and filter syntax, including `tag:`. \ No newline at end of file diff --git a/docs/guide/using-librislog/profile.md b/docs/guide/using-librislog/profile.md index a8b40e52..42a616b2 100644 --- a/docs/guide/using-librislog/profile.md +++ b/docs/guide/using-librislog/profile.md @@ -112,10 +112,11 @@ details and a list of supported dashboard integrations. ## Data Management -Two data management tools are available: +Three data management tools are available: - **Import / Export** — Export your library as JSON, CSV, or ZIP, or import from the Goodreads or Bookstats presets, Excel (XLSX), or generic CSV/JSON with field mapping and Python transforms. See [Import & Export](/guide/using-librislog/import-export). - **Data Hygiene** — Find books with missing metadata and batch-update them. See [Data Hygiene](/guide/using-librislog/data-hygiene). +- **Mass Tag Editor** — Add or remove tags on many books at once. See [Mass Tag Editor](/guide/using-librislog/mass-tag-editor). ## OIDC diff --git a/docs/releases.md b/docs/releases.md index 5fb78bf6..5c3e97d7 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -6,6 +6,12 @@ All LibrisLog releases, newest first — what's new, what was fixed, and anythin You can also browse the [GitHub Releases](https://github.com/codebude/librislog/releases) page and the [full changelog](https://github.com/codebude/librislog/commits/main). +## Unreleased + +::: tip ⭐ v1.12.0 - Mass Tag Editor +LibrisLog v1.12.0 adds a Mass Tag Editor page for adding or removing tags on many books at once. Filter and search the library, select individual books or all books matching the current filters, then apply tag changes in one step. Tags left on no book are cleaned up automatically. The change is non-breaking: no data migration and no change to existing endpoints beyond two new optional filters. +::: + ## Latest Release ::: tip ⭐ v1.11.0 — Reading Sessions & Re-reads @@ -16,6 +22,7 @@ LibrisLog v1.11.0 lets books be read more than once. Each reading is tracked as | Version | Date | Type | |---|---|---| +| [v1.12.0](#v1-12-0-mass-tag-editor) | Unreleased | Feature release | | [v1.11.0](#v1-11-0-reading-sessions-re-reads) | 2026-10-24 | Feature release | | [v1.10.0](#v1-10-0-more-book-sources-upgrades) | 2026-10-18 | Feature release | | [v1.9.0](#v1-9-0-smarter-import-search-input-ux) | 2026-09-15 | Feature release | @@ -37,6 +44,28 @@ LibrisLog v1.11.0 lets books be read more than once. Each reading is tracked as --- +## v1.12.0: Mass Tag Editor + + + +**Summary:** LibrisLog v1.12.0 adds a Mass Tag Editor page for adding or removing tags on many books at once. Filter and search the library, select individual books or all books matching the current filters, then apply tag changes in one step. Tags left on no book are cleaned up automatically. + +**Features** +- 🏷️ **Mass Tag Editor page.** New page at `/tag-editor`, reachable from Profile → Manage my data, with the same filter bar as the library (search, status, medium, acquisition, tag) plus row selection and "select all matching filters". +- 🔁 **Add and remove in one step.** Both fields in a single action, applied in chunks of 500 books with progress reporting. +- ⚠️ **Case-variant warning.** Tags that differ only in capitalization from an existing tag are flagged, with a one-click shortcut to reuse the existing spelling. +- 🧹 **Orphan tag cleanup.** Tags left on no book are deleted automatically. + +**Improvements** +- 🔍 **Exact tag filters.** `GET /api/books` and the new `GET /api/books/ids` accept `tag` and `exclude_tag` for exact tag name filtering. +- 📋 **Tag listing.** New `GET /api/tags` returns all tags with usage counts. + +**Breaking changes:** None. No data migration. Existing endpoints keep their behaviour; only two optional query parameters and new endpoints were added. + +[Compare with v1.11.0](https://github.com/codebude/librislog/compare/v1.11.0...HEAD) + +--- + ## v1.11.0: Reading Sessions & Re-reads