Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
121 changes: 99 additions & 22 deletions backend/app/routers/books.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -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"
),
Expand All @@ -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())
Expand Down Expand Up @@ -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),
Expand Down
117 changes: 117 additions & 0 deletions backend/app/routers/tags.py
Original file line number Diff line number Diff line change
@@ -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,
)
38 changes: 38 additions & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading
Loading