diff --git a/README.md b/README.md index 52f90e6c..dca46a07 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@  ·  API Reference  ·  + Release Notes +  ·  Nightly Docs

@@ -128,8 +130,6 @@ MIT ## Star History -## Star History - diff --git a/backend/alembic/versions/9a8b7c6d5e4f_add_reading_goals_to_usersettings.py b/backend/alembic/versions/9a8b7c6d5e4f_add_reading_goals_to_usersettings.py new file mode 100644 index 00000000..a72cfddf --- /dev/null +++ b/backend/alembic/versions/9a8b7c6d5e4f_add_reading_goals_to_usersettings.py @@ -0,0 +1,40 @@ +"""add reading goals to usersettings + +Revision ID: 9a8b7c6d5e4f +Revises: f1b2c3d4e5a6 +Create Date: 2026-08-26 13:15:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9a8b7c6d5e4f' +down_revision: Union[str, Sequence[str], None] = 'f1b2c3d4e5a6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('usersettings', sa.Column('goal_pages_per_day_enabled', sa.Boolean(), nullable=False, server_default=sa.false())) + op.add_column('usersettings', sa.Column('goal_pages_per_day', sa.Integer(), nullable=False, server_default='20')) + op.add_column('usersettings', sa.Column('goal_pages_per_month_enabled', sa.Boolean(), nullable=False, server_default=sa.false())) + op.add_column('usersettings', sa.Column('goal_pages_per_month', sa.Integer(), nullable=False, server_default='300')) + op.add_column('usersettings', sa.Column('goal_books_per_month_enabled', sa.Boolean(), nullable=False, server_default=sa.false())) + op.add_column('usersettings', sa.Column('goal_books_per_month', sa.Integer(), nullable=False, server_default='2')) + op.add_column('usersettings', sa.Column('goal_books_per_year_enabled', sa.Boolean(), nullable=False, server_default=sa.false())) + op.add_column('usersettings', sa.Column('goal_books_per_year', sa.Integer(), nullable=False, server_default='25')) + + +def downgrade() -> None: + op.drop_column('usersettings', 'goal_books_per_year') + op.drop_column('usersettings', 'goal_books_per_year_enabled') + op.drop_column('usersettings', 'goal_books_per_month') + op.drop_column('usersettings', 'goal_books_per_month_enabled') + op.drop_column('usersettings', 'goal_pages_per_month') + op.drop_column('usersettings', 'goal_pages_per_month_enabled') + op.drop_column('usersettings', 'goal_pages_per_day') + op.drop_column('usersettings', 'goal_pages_per_day_enabled') \ No newline at end of file diff --git a/backend/alembic/versions/a1b2c3d4e5f7_add_gamification_enabled_to_usersettings.py b/backend/alembic/versions/a1b2c3d4e5f7_add_gamification_enabled_to_usersettings.py new file mode 100644 index 00000000..9e77848e --- /dev/null +++ b/backend/alembic/versions/a1b2c3d4e5f7_add_gamification_enabled_to_usersettings.py @@ -0,0 +1,29 @@ +"""add gamification enabled to usersettings + +Revision ID: a1b2c3d4e5f7 +Revises: 9a8b7c6d5e4f +Create Date: 2026-08-26 15:20:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a1b2c3d4e5f7' +down_revision: Union[str, Sequence[str], None] = '9a8b7c6d5e4f' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + 'usersettings', + sa.Column('gamification_enabled', sa.Boolean(), nullable=False, server_default=sa.true()) + ) + + +def downgrade() -> None: + op.drop_column('usersettings', 'gamification_enabled') \ No newline at end of file diff --git a/backend/alembic/versions/c7a8d9e1f2a3_create_author_and_book_author_tables.py b/backend/alembic/versions/c7a8d9e1f2a3_create_author_and_book_author_tables.py new file mode 100644 index 00000000..b72e6c72 --- /dev/null +++ b/backend/alembic/versions/c7a8d9e1f2a3_create_author_and_book_author_tables.py @@ -0,0 +1,104 @@ +"""create author and book_author tables and backfill + +Revision ID: c7a8d9e1f2a3 +Revises: e2f3a4b5c6d7 +Create Date: 2026-08-24 21:00:00 +""" + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "c7a8d9e1f2a3" +down_revision = "e2f3a4b5c6d7" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not inspector.has_table("author"): + op.create_table( + "author", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.ForeignKeyConstraint(["user_id"], ["user.id"]), + sa.UniqueConstraint("user_id", "name", name="uq_author_user_id_name"), + sa.PrimaryKeyConstraint("id"), + ) + + existing_author_indexes = {idx["name"] for idx in inspector.get_indexes("author")} + if "ix_author_user_id" not in existing_author_indexes: + op.create_index("ix_author_user_id", "author", ["user_id"], unique=False) + if "ix_author_name" not in existing_author_indexes: + op.create_index("ix_author_name", "author", ["name"], unique=False) + + if not inspector.has_table("book_author"): + op.create_table( + "book_author", + sa.Column("book_id", sa.Integer(), nullable=False), + sa.Column("author_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["book_id"], ["book.id"]), + sa.ForeignKeyConstraint(["author_id"], ["author.id"]), + sa.PrimaryKeyConstraint("book_id", "author_id"), + ) + + existing_book_author_indexes = {idx["name"] for idx in inspector.get_indexes("book_author")} + if "ix_book_author_book_id" not in existing_book_author_indexes: + op.create_index("ix_book_author_book_id", "book_author", ["book_id"], unique=False) + if "ix_book_author_author_id" not in existing_book_author_indexes: + op.create_index("ix_book_author_author_id", "book_author", ["author_id"], unique=False) + + # Backfill authors from the legacy book.author column. Each legacy value is + # treated as a single author name so names like "Asimov, Isaac" are preserved. + rows = bind.execute( + sa.text("SELECT id, user_id, author FROM book WHERE author IS NOT NULL AND author <> ''") + ).fetchall() + + for book_id, user_id, raw in rows: + name = " ".join(raw.strip().split()) + if not name: + continue + + author_id = bind.execute( + sa.text("SELECT id FROM author WHERE user_id = :user_id AND name = :name"), + {"user_id": user_id, "name": name}, + ).scalar() + if author_id is None: + author_id = bind.execute( + sa.text("INSERT INTO author (user_id, name) VALUES (:user_id, :name) RETURNING id"), + {"user_id": user_id, "name": name}, + ).scalar_one() + + bind.execute( + sa.text( + "INSERT OR IGNORE INTO book_author (book_id, author_id) VALUES (:book_id, :author_id)" + ), + {"book_id": book_id, "author_id": author_id}, + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if inspector.has_table("book_author"): + existing_book_author_indexes = {idx["name"] for idx in inspector.get_indexes("book_author")} + if "ix_book_author_author_id" in existing_book_author_indexes: + op.drop_index("ix_book_author_author_id", table_name="book_author") + if "ix_book_author_book_id" in existing_book_author_indexes: + op.drop_index("ix_book_author_book_id", table_name="book_author") + op.drop_table("book_author") + + if inspector.has_table("author"): + existing_author_indexes = {idx["name"] for idx in inspector.get_indexes("author")} + if "ix_author_name" in existing_author_indexes: + op.drop_index("ix_author_name", table_name="author") + if "ix_author_user_id" in existing_author_indexes: + op.drop_index("ix_author_user_id", table_name="author") + op.drop_table("author") \ No newline at end of file diff --git a/backend/alembic/versions/f1b2c3d4e5a6_drop_author_column_from_book.py b/backend/alembic/versions/f1b2c3d4e5a6_drop_author_column_from_book.py new file mode 100644 index 00000000..8b51979b --- /dev/null +++ b/backend/alembic/versions/f1b2c3d4e5a6_drop_author_column_from_book.py @@ -0,0 +1,42 @@ +"""drop author column from book + +Revision ID: f1b2c3d4e5a6 +Revises: c7a8d9e1f2a3 +Create Date: 2026-08-24 21:10:00 +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "f1b2c3d4e5a6" +down_revision = "c7a8d9e1f2a3" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("book", schema=None) as batch_op: + batch_op.drop_column("author") + + +def downgrade() -> None: + with op.batch_alter_table("book", schema=None) as batch_op: + batch_op.add_column(sa.Column("author", sa.String(), nullable=True, server_default="")) + + # Re-populate book.author from the relation tables before the author tables + # are dropped by the previous revision's downgrade. + bind = op.get_bind() + bind.execute( + sa.text( + """ + UPDATE book SET author = ( + SELECT group_concat(a.name, ', ') + FROM book_author ba + JOIN author a ON ba.author_id = a.id + WHERE ba.book_id = book.id + ) + """ + ) + ) \ No newline at end of file diff --git a/backend/app/models.py b/backend/app/models.py index b3a48178..99e771ea 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -70,7 +70,6 @@ class Book(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) title: str = Field(index=True) subtitle: Optional[str] = None - author: str = Field(default="", index=True) isbn: Optional[str] = Field(default=None) cover_url: Optional[str] = None publisher: Optional[str] = None @@ -128,6 +127,30 @@ class BookTag(SQLModel, table=True): tag_id: int = Field(foreign_key="tag.id", primary_key=True, index=True) +class Author(SQLModel, table=True): + """A user-specific author name that can be associated with books.""" + + __tablename__: str = "author" + __table_args__ = (sa.UniqueConstraint("user_id", "name", name="uq_author_user_id_name"),) + + id: Optional[int] = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user.id", index=True) + name: str = Field(index=True) + created_at: datetime = Field( + default_factory=utcnow, + sa_column=Column(UtcDateTime, default=utcnow) + ) + + +class BookAuthor(SQLModel, table=True): + """Many-to-many association between books and authors.""" + + __tablename__: str = "book_author" + + book_id: int = Field(foreign_key="book.id", primary_key=True) + author_id: int = Field(foreign_key="author.id", primary_key=True, index=True) + + class User(SQLModel, table=True): """A user account.""" @@ -160,6 +183,15 @@ class UserSettings(SQLModel, table=True): timezone: str = Field(default="UTC", max_length=64) theme: str = Field(default="light", max_length=20) custom_theme: Optional[str] = Field(default=None, max_length=30) + goal_pages_per_day_enabled: bool = Field(default=False) + goal_pages_per_day: int = Field(default=20, ge=1) + goal_pages_per_month_enabled: bool = Field(default=False) + goal_pages_per_month: int = Field(default=300, ge=1) + goal_books_per_month_enabled: bool = Field(default=False) + goal_books_per_month: int = Field(default=2, ge=1) + goal_books_per_year_enabled: bool = Field(default=False) + goal_books_per_year: int = Field(default=25, ge=1) + gamification_enabled: bool = Field(default=True) class ApiKey(SQLModel, table=True): diff --git a/backend/app/routers/books.py b/backend/app/routers/books.py index eea30027..aceadb64 100644 --- a/backend/app/routers/books.py +++ b/backend/app/routers/books.py @@ -7,12 +7,12 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status import sqlalchemy as sa from sqlalchemy.exc import IntegrityError -from sqlmodel import Session, col, func, or_, select +from sqlmodel import Session, col, func, select from app.auth import require_user from app.config import settings from app.database import get_session -from app.models import AcquisitionStatus, Book, BookTag, ReadingProgress, ReadingStatus, Tag, User +from app.models import AcquisitionStatus, Author, Book, BookAuthor, BookTag, ReadingProgress, ReadingStatus, Tag, User from app.schemas import ( BookCreate, BookListResponse, @@ -26,12 +26,20 @@ SuggestionList, TagCloudEntry, ) +from app.services.authors import ( + cleanup_orphan_authors, + join_authors, + load_authors_batch, + resolve_authors_payload, + sync_book_authors, +) from app.services.cover_storage import ( delete_cover_file, local_cover_filename, ) from app.services.cover_import import import_cover_from_url, is_external_cover_url from app.services.quote_cache import get_or_fetch_dashboard_quote +from app.services.search import _escape_like, apply_search_filter from app.services.tags import build_book_read, cleanup_orphan_tags, load_tags_batch, sync_book_tags from app.time_utils import utcnow @@ -129,11 +137,14 @@ def _raise_integrity_conflict(exc: IntegrityError) -> None: raise -def _build_book_read_with_tags(book: Book, tags_text: str | None) -> BookRead: - """Build a BookRead from a Book model with a pre-resolved tags string.""" +def _build_book_read_with_tags(book: Book, tags_text: str | None, authors: list[str] | None = None) -> BookRead: + """Build a BookRead from a Book model with pre-resolved tags and authors.""" payload = book.model_dump() payload.pop("user_id", None) payload["tags"] = tags_text + authors = authors or [] + payload["authors"] = authors + payload["author"] = join_authors(authors) return BookRead.model_validate(payload) @@ -141,7 +152,15 @@ def _build_book_read_with_tags(book: Book, tags_text: str | None) -> BookRead: def list_books( status: Optional[ReadingStatus] = Query(default=None), acquisition_status: Optional[AcquisitionStatus] = Query(default=None), - q: Optional[str] = Query(default=None), + q: Optional[str] = Query( + default=None, + description=( + "Search phrase. Use : to restrict a term to a single field " + "(author, publisher, title, tag, language, possession, notes, description). " + "Wrap multi-word values in double quotes (e.g. author:\"Marlen Haushofer\") and " + "prefix any term with - to negate it (e.g. tag:cars -tag:audi)." + ), + ), has_cover: Optional[bool] = Query(default=None), sort: Literal["title", "date_added", "date_started", "date_finished", "rating"] = Query( default="date_added" @@ -172,20 +191,8 @@ def list_books( base_statement = base_statement.where(Book.acquisition_status == acquisition_status) if q: - pattern = f"%{q}%" - matching_tag_book_ids = select(BookTag.book_id).join(Tag, col(Tag.id) == BookTag.tag_id).where( - Tag.user_id == current_user.id, - col(Tag.name).ilike(pattern), - ) - base_statement = base_statement.where( - or_( - col(Book.title).ilike(pattern), - col(Book.subtitle).ilike(pattern), - col(Book.author).ilike(pattern), - col(Book.blurb).ilike(pattern), - col(Book.id).in_(matching_tag_book_ids), - ) - ) + 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: @@ -230,9 +237,10 @@ def list_books( logger.debug("list_books — returning %d/%d book(s)", len(books), total) book_ids = [b.id for b in books if b.id is not None] book_tags_map = load_tags_batch(session, book_ids) if book_ids else {} + book_authors_map = load_authors_batch(session, book_ids) if book_ids else {} return BookListResponse( books=[ - _build_book_read_with_tags(book, book_tags_map.get(book.id)) + _build_book_read_with_tags(book, book_tags_map.get(book.id), book_authors_map.get(book.id)) for book in books ], total=total, @@ -328,17 +336,17 @@ def _suggest_field( """Return distinct values for a Book column matching the query.""" if not q.strip(): return [] - pattern = f"%{q}%" - col = getattr(Book, column) + pattern = f"%{_escape_like(q)}%" + column_expr = getattr(Book, column) rows = session.exec( - select(col) + select(column_expr) .where( Book.user_id == user_id, - col.isnot(None), - col.ilike(pattern), + column_expr.isnot(None), + column_expr.ilike(pattern, escape="\\"), ) .distinct() - .order_by(col) + .order_by(column_expr) .limit(limit) ).all() return list(rows) @@ -351,10 +359,22 @@ def suggest_authors( current_user: User = Depends(require_user), session: Session = Depends(get_session), ) -> SuggestionList: - """Autocomplete author names from the user's existing books.""" + """Autocomplete author names from the user's existing authors.""" assert current_user.id is not None - suggestions = _suggest_field(session, current_user.id, "author", q, limit) - return SuggestionList(suggestions=suggestions) + if not q.strip(): + return SuggestionList(suggestions=[]) + pattern = f"%{_escape_like(q)}%" + rows = session.exec( + select(Author.name) + .where( + Author.user_id == current_user.id, + col(Author.name).ilike(pattern, escape="\\"), + ) + .distinct() + .order_by(Author.name) + .limit(limit) + ).all() + return SuggestionList(suggestions=list(rows)) @router.get("/suggestions/publishers", response_model=SuggestionList) @@ -423,6 +443,8 @@ async def create_book( book_data["language"] = _normalize_language(book_data.get("language")) book_data["cover_url"] = cover_url book_data.pop("tags", None) + book_data.pop("author", None) + book_data.pop("authors", None) book_data["user_id"] = current_user.id _validate_dates(book_data) book = Book.model_validate(book_data) @@ -433,6 +455,8 @@ async def create_book( session.rollback() _raise_integrity_conflict(exc) sync_book_tags(session, current_user.id, book.id or 0, book_in.tags) + names = resolve_authors_payload(book_in.author, book_in.authors) or [] + sync_book_authors(session, current_user.id, book.id or 0, names) try: session.commit() except IntegrityError as exc: @@ -478,6 +502,10 @@ async def update_book( update_data["language"] = _normalize_language(update_data.get("language")) tags_provided = "tags" in update_data tags_raw = update_data.pop("tags", None) if tags_provided else None + authors_payload = resolve_authors_payload( + update_data.pop("author", None), update_data.pop("authors", None) + ) + authors_provided = authors_payload is not None target_status = update_data.get("reading_status", book.reading_status) # Download external cover URL -> local file. @@ -523,6 +551,10 @@ async def update_book( assert book.id is not None sync_book_tags(session, current_user.id, book.id, tags_raw) cleanup_orphan_tags(session, current_user.id) + if authors_provided: + assert book.id is not None + sync_book_authors(session, current_user.id, book.id, authors_payload) + cleanup_orphan_authors(session, current_user.id) try: session.commit() except IntegrityError as exc: @@ -679,11 +711,14 @@ def delete_book( for link in session.exec(select(BookTag).where(BookTag.book_id == book.id)).all(): session.delete(link) + for link in session.exec(select(BookAuthor).where(BookAuthor.book_id == book.id)).all(): + session.delete(link) for entry in session.exec( select(ReadingProgress).where(ReadingProgress.book_id == book.id) ).all(): session.delete(entry) session.delete(book) cleanup_orphan_tags(session, current_user.id) + cleanup_orphan_authors(session, current_user.id) session.commit() logger.info("Deleted book id=%s", book_id) diff --git a/backend/app/routers/data.py b/backend/app/routers/data.py index 94cb0b23..876757df 100644 --- a/backend/app/routers/data.py +++ b/backend/app/routers/data.py @@ -5,7 +5,7 @@ from datetime import datetime from typing import Any -from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile +from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile from fastapi.responses import StreamingResponse from sqlalchemy.exc import IntegrityError from sqlmodel import Session, col, select @@ -34,6 +34,7 @@ from app.services.data_import import ( BOOK_IMPORT_FIELDS, PREDEFINED_MAPPINGS, + canonicalize_mapping, compute_schema_fingerprint, execute_import, get_predefined_mapping, @@ -54,7 +55,7 @@ def _mapping_read(model: ImportMapping) -> DataImportMappingRead: id=model.id or 0, name=model.name, source_fields=json.loads(model.source_fields_json), - mapping={k: ImportFieldConfig(**v) for k, v in raw_mapping.items()}, + mapping=canonicalize_mapping({k: ImportFieldConfig(**v) for k, v in raw_mapping.items()}), created_at=model.created_at, updated_at=model.updated_at, is_predefined=False, @@ -87,9 +88,13 @@ def export_data( @router.post("/import/parse", response_model=DataImportParseResponse) async def parse_import_file( file: UploadFile = File(...), + delimiter: str = Form(","), current_user: User = Depends(require_user), ) -> DataImportParseResponse: - """Parse an uploaded CSV or JSON import file and return field info and samples.""" + """Parse an uploaded CSV or JSON import file and return field info and samples. + + ``delimiter`` is the single-character CSV field separator (ignored for JSON). + """ assert current_user.id is not None allowed_content_types = { "text/csv", @@ -101,7 +106,7 @@ async def parse_import_file( if file.content_type and file.content_type not in allowed_content_types: raise HTTPException(status_code=415, detail="Unsupported upload content type. Use CSV or JSON files.") try: - payload = parse_upload(await file.read(), file.filename or "upload", current_user.id) + payload = parse_upload(await file.read(), file.filename or "upload", current_user.id, delimiter) except (ValueError, json.JSONDecodeError) as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return DataImportParseResponse.model_validate(payload) @@ -143,7 +148,7 @@ def save_import_mapping( ) ).first() - mapping_dict = {k: v.model_dump() for k, v in body.mapping.items()} + mapping_dict = {k: v.model_dump() for k, v in canonicalize_mapping(body.mapping).items()} if existing: existing.source_fields_json = json.dumps(body.source_fields) existing.mapping_json = json.dumps(mapping_dict) @@ -226,7 +231,7 @@ def get_import_mapping( id=mapping_id, name=str(pm.get("name", "")), source_fields=list(raw_sources), - mapping={k: ImportFieldConfig(**v) for k, v in raw_mapping.items()}, + mapping=canonicalize_mapping({k: ImportFieldConfig(**v) for k, v in raw_mapping.items()}), created_at=datetime(2000, 1, 1), updated_at=datetime(2000, 1, 1), is_predefined=True, diff --git a/backend/app/routers/hygiene.py b/backend/app/routers/hygiene.py index 8ea294ba..46e509cd 100644 --- a/backend/app/routers/hygiene.py +++ b/backend/app/routers/hygiene.py @@ -10,7 +10,7 @@ from app.auth import require_user from app.config import settings from app.database import get_session -from app.models import Book, User +from app.models import Book, BookAuthor, User from app.schemas import ( HygieneAttribute, HygieneBatchUpdateRequest, @@ -18,8 +18,9 @@ HygieneMissingBook, HygieneMissingResponse, ) +from app.services.authors import cleanup_orphan_authors, join_authors, load_authors_batch, sync_book_authors from app.services.cover_import import import_cover_from_url, is_external_cover_url -from app.services.tags import build_book_read +from app.services.tags import load_tags_batch logger = logging.getLogger(__name__) @@ -30,18 +31,24 @@ def _missing_condition(attr: HygieneAttribute): """Return a SQLAlchemy filter condition for a given attribute being missing.""" - col = getattr(Book, attr.value) if attr == HygieneAttribute.author: - return or_(col == "", col.is_(None)) + return ~col(Book.id).in_( + select(BookAuthor.book_id).where(BookAuthor.book_id == col(Book.id)) + ) + col_expr = getattr(Book, attr.value) if attr == HygieneAttribute.page_count: - return or_(col == 0, col.is_(None)) - return col.is_(None) + return or_(col_expr == 0, col_expr.is_(None)) + return col_expr.is_(None) -def _compute_missing_attributes(book: Book) -> list[HygieneAttribute]: +def _compute_missing_attributes(book: Book, author_names: list[str]) -> list[HygieneAttribute]: """Return the list of hygiene attributes that are missing for a given book.""" missing: list[HygieneAttribute] = [] for attr in HygieneAttribute: + if attr == HygieneAttribute.author: + if not author_names: + missing.append(attr) + continue val = getattr(book, attr.value) is_missing = val is None or val == "" or val == 0 if is_missing: @@ -102,21 +109,26 @@ def list_missing( ).all() hygiene_books = [] + book_ids = [book.id for book in books if book.id is not None] + tags_map = load_tags_batch(session, book_ids) + authors_map = load_authors_batch(session, book_ids) for book in books: - br = build_book_read(session, book) - missing_attrs = _compute_missing_attributes(book) + assert book.id is not None + author_names = authors_map.get(book.id, []) + missing_attrs = _compute_missing_attributes(book, author_names) hygiene_books.append(HygieneMissingBook( - id=br.id, - title=br.title, - author=br.author, - isbn=br.isbn, - publisher=br.publisher, - published_year=br.published_year, - blurb=br.blurb, - language=br.language, - subtitle=br.subtitle, - page_count=br.page_count or 0, - cover_url=br.cover_url, + id=book.id, + title=book.title, + author=join_authors(author_names), + authors=author_names, + isbn=book.isbn, + publisher=book.publisher, + published_year=book.published_year, + blurb=book.blurb, + language=book.language, + subtitle=book.subtitle, + page_count=book.page_count or 0, + cover_url=book.cover_url, missing_attributes=[a for a in requested if a in missing_attrs], )) @@ -233,8 +245,18 @@ async def batch_update( skipped_ids: list[int] = [] to_update_ids: list[int] = [] + current_authors_map = ( + load_authors_batch(session, [b.id for b in books if b.id is not None]) + if req.field == HygieneAttribute.author + else {} + ) for book in books: - current_val = getattr(book, req.field.value) + if req.field == HygieneAttribute.author: + # Compare against the current author set so multi-author books are + # only skipped when they already contain exactly the target author. + current_val = ", ".join(current_authors_map.get(book.id, [])) or None + else: + current_val = getattr(book, req.field.value) if current_val == req.value: skipped_ids.append(book.id) # ty: ignore[invalid-argument-type] else: @@ -242,19 +264,36 @@ async def batch_update( updated = 0 if to_update_ids: - try: - stmt = ( - sqlmodel_update(Book) - .where(col(Book.id).in_(to_update_ids)) - .values({req.field.value: req.value}) - ) - updated = len(to_update_ids) - session.exec(stmt) - session.commit() - except Exception: - session.rollback() - logger.exception("Batch update failed for %d books", len(to_update_ids)) - raise HTTPException(status_code=500, detail="Batch update failed due to a database error") + if req.field == HygieneAttribute.author: + # Authors live in a relation table; a bulk SQL update does not work. + assert current_user.id is not None + for book in books: + if book.id not in set(to_update_ids): + continue + assert book.id is not None + sync_book_authors(session, current_user.id, book.id, [str(req.value)]) + updated += 1 + cleanup_orphan_authors(session, current_user.id) + try: + session.commit() + except Exception: + session.rollback() + logger.exception("Batch author update failed for %d books", len(to_update_ids)) + raise HTTPException(status_code=500, detail="Batch update failed due to a database error") + else: + try: + stmt = ( + sqlmodel_update(Book) + .where(col(Book.id).in_(to_update_ids)) + .values({req.field.value: req.value}) + ) + updated = len(to_update_ids) + session.exec(stmt) + session.commit() + except Exception: + session.rollback() + logger.exception("Batch update failed for %d books", len(to_update_ids)) + raise HTTPException(status_code=500, detail="Batch update failed due to a database error") return HygieneBatchUpdateResponse( updated=updated, diff --git a/backend/app/routers/import_.py b/backend/app/routers/import_.py index d094bdc9..acde920c 100644 --- a/backend/app/routers/import_.py +++ b/backend/app/routers/import_.py @@ -16,6 +16,7 @@ from app.models import Book, User from app.schemas import BookImportCandidate, BookImportRequest, BookRead from app.services import book_import +from app.services.authors import resolve_authors_payload, sync_book_authors from app.services.cover_storage import download_cover from app.services.tags import build_book_read, sync_book_tags @@ -145,7 +146,6 @@ async def import_book( book = Book( title=c.title, subtitle=c.subtitle, - author=c.author or "", isbn=c.isbn, cover_url=cover_url, publisher=c.publisher, @@ -164,6 +164,8 @@ async def import_book( session.rollback() _raise_integrity_conflict(exc) sync_book_tags(session, current_user.id, book.id or 0, c.tags) + names = resolve_authors_payload(c.author, c.authors) or [] + sync_book_authors(session, current_user.id, book.id or 0, names) try: session.commit() except IntegrityError as exc: diff --git a/backend/app/routers/profile.py b/backend/app/routers/profile.py index eddff7e4..00bf1c2f 100644 --- a/backend/app/routers/profile.py +++ b/backend/app/routers/profile.py @@ -104,6 +104,15 @@ def get_settings( timezone=settings.timezone, theme=settings.theme, custom_theme=settings.custom_theme, + goal_pages_per_day_enabled=settings.goal_pages_per_day_enabled, + goal_pages_per_day=settings.goal_pages_per_day, + goal_pages_per_month_enabled=settings.goal_pages_per_month_enabled, + goal_pages_per_month=settings.goal_pages_per_month, + goal_books_per_month_enabled=settings.goal_books_per_month_enabled, + goal_books_per_month=settings.goal_books_per_month, + goal_books_per_year_enabled=settings.goal_books_per_year_enabled, + goal_books_per_year=settings.goal_books_per_year, + gamification_enabled=settings.gamification_enabled, ) @@ -133,6 +142,15 @@ def update_settings( timezone=settings.timezone, theme=settings.theme, custom_theme=settings.custom_theme, + goal_pages_per_day_enabled=settings.goal_pages_per_day_enabled, + goal_pages_per_day=settings.goal_pages_per_day, + goal_pages_per_month_enabled=settings.goal_pages_per_month_enabled, + goal_pages_per_month=settings.goal_pages_per_month, + goal_books_per_month_enabled=settings.goal_books_per_month_enabled, + goal_books_per_month=settings.goal_books_per_month, + goal_books_per_year_enabled=settings.goal_books_per_year_enabled, + goal_books_per_year=settings.goal_books_per_year, + gamification_enabled=settings.gamification_enabled, ) @@ -158,8 +176,8 @@ def reset_data( raise logger.warning( - "User %s reset personal data: books=%s tags=%s progress_entries=%s", - current_user.id, deleted.books, deleted.tags, deleted.progress_entries, + "User %s reset personal data: books=%s tags=%s authors=%s progress_entries=%s", + current_user.id, deleted.books, deleted.tags, deleted.authors, deleted.progress_entries, ) return DataResetResponse( @@ -167,6 +185,7 @@ def reset_data( deleted=DataResetDeleted( books=deleted.books, tags=deleted.tags, + authors=deleted.authors, progress_entries=deleted.progress_entries, ), ) diff --git a/backend/app/routers/statistics.py b/backend/app/routers/statistics.py index c14d2db4..900f25a0 100644 --- a/backend/app/routers/statistics.py +++ b/backend/app/routers/statistics.py @@ -2,7 +2,7 @@ import calendar from collections import Counter, defaultdict -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, timedelta, timezone from statistics import mean from types import SimpleNamespace from typing import Optional @@ -14,11 +14,15 @@ from app.auth import require_user from app.database import get_session -from app.models import AcquisitionStatus, Book, ReadingProgress, ReadingStatus, User, UserSettings +from app.models import AcquisitionStatus, Author, Book, BookAuthor, ReadingProgress, ReadingStatus, User, UserSettings +from app.services.authors import join_authors, load_authors_batch from app.schemas import ( AcquisitionStatusDistribution, DailyPages, DailyPagesResponse, + GamificationResponse, + GoalProgress, + GoalType, LanguageDistribution, MonthlyBooks, MonthlyPages, @@ -34,16 +38,20 @@ router = APIRouter(prefix="/api/statistics", tags=["statistics"]) -def _user_timezone(session: Session, user_id: int) -> ZoneInfo: - """Return the user's configured timezone, falling back to UTC.""" - settings = session.exec(select(UserSettings).where(UserSettings.user_id == user_id)).first() - timezone_name = settings.timezone if settings and settings.timezone else "UTC" +def _zone_from_name(timezone_name: str | None) -> ZoneInfo: + """Return a ZoneInfo for *timezone_name*, falling back to UTC.""" try: - return ZoneInfo(timezone_name) + return ZoneInfo(timezone_name or "UTC") except ZoneInfoNotFoundError: return ZoneInfo("UTC") +def _user_timezone(session: Session, user_id: int) -> ZoneInfo: + """Return the user's configured timezone, falling back to UTC.""" + settings = session.exec(select(UserSettings).where(UserSettings.user_id == user_id)).first() + return _zone_from_name(settings.timezone if settings else None) + + def _month_key(dt: datetime, tz: ZoneInfo) -> str: """Format a datetime as ``YYYY-MM`` in the given timezone.""" local = dt.astimezone(tz) @@ -223,6 +231,253 @@ def _compute_pages_per_month_from_books(books: list[Book], tz: ZoneInfo) -> dict return monthly +def _day_key(dt: datetime, tz: ZoneInfo) -> str: + """Return the ``YYYY-MM-DD`` calendar day of *dt* in *tz*.""" + return dt.astimezone(tz).strftime("%Y-%m-%d") + + +def current_streak(active_dates: set[str], today: date) -> int: + """Return the number of consecutive active days ending at *today*. + + Today counts as the first day when it is active; otherwise the streak + starts at yesterday, so a not-yet-logged today does not break an ongoing + streak. The streak is 0 when neither today nor yesterday are active. + """ + streak = 0 + day = today + first = True + while True: + if day.isoformat() in active_dates: + streak += 1 + elif not first: + break + first = False + day -= timedelta(days=1) + return streak + + +def longest_streak(active_dates: set[str]) -> tuple[int, Optional[str], Optional[str]]: + """Return the longest consecutive run of active dates. + + Returns ``(length, start, end)`` with ``YYYY-MM-DD`` keys. Ties are + broken in favour of the most recent run. When there is no activity at + all the result is ``(0, None, None)``. + """ + if not active_dates: + return 0, None, None + ordered = sorted(active_dates) + best_len, best_start, best_end = 0, None, None + run_start = ordered[0] + run_len = 1 + prev = ordered[0] + for current in ordered[1:]: + if (date.fromisoformat(current) - date.fromisoformat(prev)).days == 1: + run_len += 1 + else: + if run_len >= best_len: + best_len, best_start, best_end = run_len, run_start, prev + run_start, run_len = current, 1 + prev = current + if run_len >= best_len: + best_len, best_start, best_end = run_len, run_start, prev + return best_len, best_start, best_end + + +def _pages_logged_on_day(entries: list, tz: ZoneInfo, day_key: str) -> int: + """Sum the positive page-deltas logged on *day_key*. + + A delta is the page gain between two consecutive progress entries of the + same book, attributed to the calendar day (in *tz*) of the later entry. + """ + grouped: dict[int, list] = {} + for entry in entries: + grouped.setdefault(entry.book_id, []).append(entry) + total = 0 + for book_entries in grouped.values(): + book_entries.sort(key=lambda e: (e.created_at, e.page)) + for prev, curr in zip(book_entries, book_entries[1:]): + delta = curr.page - prev.page + if delta > 0 and _day_key(curr.created_at, tz) == day_key: + total += delta + return total + + +def _compute_goal_progress( + tz: ZoneInfo, + settings: UserSettings, + today: datetime, + entries: list, + books: list, + book_ids_with_progress: set[int], +) -> list[GoalProgress]: + """Compute current progress for every enabled reading goal. + + Disabled goals are omitted from the response; the dashboard only shows + goals the user opted into. + """ + today_key = today.strftime("%Y-%m-%d") + current_month_key = today.strftime("%Y-%m") + current_year = today.year + + fallback_books = [ + b + for b in books + if b.id not in book_ids_with_progress + and b.reading_status == ReadingStatus.read + and b.date_started + and b.date_finished + and b.page_count + ] + + # Mirror get_statistics: anchor every book with progress at page 0 on its + # start date so the first progress delta is attributed to the reading span, + # keeping the pages-per-month goal consistent with the statistics chart. + virtual_entries = [ + SimpleNamespace(book_id=b.id, page=0, created_at=b.date_started) + for b in books + if b.id in book_ids_with_progress + and b.date_started + and not (b.reading_status == ReadingStatus.read and not b.date_finished) + ] + + goals_spec = [ + (GoalType.pages_per_day, settings.goal_pages_per_day_enabled, settings.goal_pages_per_day), + (GoalType.pages_per_month, settings.goal_pages_per_month_enabled, settings.goal_pages_per_month), + (GoalType.books_per_month, settings.goal_books_per_month_enabled, settings.goal_books_per_month), + (GoalType.books_per_year, settings.goal_books_per_year_enabled, settings.goal_books_per_year), + ] + + results: list[GoalProgress] = [] + for goal_type, enabled, target in goals_spec: + if not enabled: + continue + current = _goal_current_value( + goal_type, tz, today_key, current_month_key, current_year, + entries, books, fallback_books, virtual_entries, + ) + results.append( + GoalProgress(type=goal_type, target=target, current=current, reached=current >= target) + ) + return results + + +def _goal_current_value( + goal_type: GoalType, + tz: ZoneInfo, + today_key: str, + current_month_key: str, + current_year: int, + entries: list, + books: list, + fallback_books: list, + virtual_entries: list, +) -> int: + """Return the current value for a single reading goal.""" + if goal_type == GoalType.pages_per_day: + total = _pages_logged_on_day(entries, tz, today_key) + for b in fallback_books: + if _day_key(b.date_finished, tz) == today_key: + total += b.page_count + return total + + if goal_type == GoalType.pages_per_month: + monthly = _compute_pages_per_month_from_progress(entries + virtual_entries, tz) + for k, v in _compute_pages_per_month_from_books(fallback_books, tz).items(): + monthly[k] += v + return int(round(monthly.get(current_month_key, 0))) + + if goal_type == GoalType.books_per_month: + return sum( + 1 + for b in books + if b.reading_status == ReadingStatus.read + and b.date_finished is not None + and _month_key(b.date_finished, tz) == current_month_key + ) + + if goal_type == GoalType.books_per_year: + return sum( + 1 + for b in books + if b.reading_status == ReadingStatus.read + and b.date_finished is not None + and b.date_finished.astimezone(tz).year == current_year + ) + + return 0 + + +@router.get("/gamification", response_model=GamificationResponse) +def get_gamification( + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> GamificationResponse: + """Return dashboard gamification data — reading streaks and goal progress.""" + assert current_user.id is not None + + settings = session.exec( + select(UserSettings).where(UserSettings.user_id == current_user.id) + ).first() + tz = _zone_from_name(settings.timezone if settings else None) + today = datetime.now(tz) + if not settings: + settings = UserSettings(user_id=current_user.id, language="en") + + if not settings.gamification_enabled: + return GamificationResponse( + enabled=False, + current_streak=0, + longest_streak=0, + longest_streak_start=None, + longest_streak_end=None, + goals=[], + ) + + # Only the columns needed for streaks and goal progress are loaded; the + # per-request cost still grows with lifetime library size, which is + # acceptable for typical personal-library volumes. + entries = list( + session.exec( + select(ReadingProgress) + .where(ReadingProgress.user_id == current_user.id) + .order_by(col(ReadingProgress.book_id), col(ReadingProgress.created_at)) + ).all() + ) + books = list( + session.exec( + select(Book) + .where(Book.user_id == current_user.id) + .order_by(col(Book.id)) + ).all() + ) + book_ids_with_progress = {entry.book_id for entry in entries} + + active_dates = {_day_key(entry.created_at, tz) for entry in entries} + for book in books: + if ( + book.id not in book_ids_with_progress + and book.reading_status == ReadingStatus.read + and book.date_finished is not None + ): + active_dates.add(_day_key(book.date_finished, tz)) + + current = current_streak(active_dates, today.date()) + longest, longest_start, longest_end = longest_streak(active_dates) + + goals = _compute_goal_progress( + tz, settings, today, entries, books, book_ids_with_progress + ) + + return GamificationResponse( + enabled=True, + current_streak=current, + longest_streak=longest, + longest_streak_start=longest_start, + longest_streak_end=longest_end, + goals=goals, + ) + + @router.get("/pages-per-day", response_model=DailyPagesResponse) def get_pages_per_day( days: int = Query(default=365, ge=1, le=730), @@ -349,6 +604,10 @@ def get_statistics( current_year = now.year books = list(session.exec(select(Book).where(Book.user_id == current_user.id)).all()) + total_authors = session.exec( + select(func.count()).select_from(Author).where(Author.user_id == current_user.id) + ).one() + status_counts = Counter(book.reading_status for book in books) status_distribution = StatusDistribution( want_to_read=status_counts.get(ReadingStatus.want_to_read, 0), @@ -509,25 +768,37 @@ def get_statistics( else: books_finished_per_year = [] - author_counts: Counter[str] = Counter() - for book in books: - if book.author and book.author.strip(): - author_counts[book.author.strip()] += 1 + author_count_label = func.count(func.distinct(BookAuthor.book_id)).label("cnt") + author_count_rows = session.exec( + select(Author.name, author_count_label) + .join(BookAuthor, col(BookAuthor.author_id) == col(Author.id)) + .join(Book, col(Book.id) == col(BookAuthor.book_id)) + .where(Book.user_id == current_user.id) + .group_by(col(Author.id)) + .order_by(author_count_label.desc(), col(Author.name).asc()) + .limit(3) + ).all() + author_counts = Counter({name: count for name, count in author_count_rows}) top_authors: list[TopAuthor] = [] if author_counts: - author_items = sorted(author_counts.items(), key=lambda item: item[0].lower()) - top_author_counts = sorted(author_items, key=lambda item: item[1], reverse=True)[:3] + top_author_counts = author_counts.most_common(3) top_author_names = [name for name, _ in top_author_counts] covers_by_author: dict[str, list[TopAuthorCover]] = {} for author_name in top_author_names: max_slots = min(5, author_counts[author_name]) + book_ids_with_author = select(BookAuthor.book_id).join( + Author, col(Author.id) == col(BookAuthor.author_id) + ).where( + Author.user_id == current_user.id, + Author.name == author_name, + ) cover_rows = session.exec( select(Book.id, Book.title, Book.reading_status, Book.cover_url) .where( Book.user_id == current_user.id, - Book.author == author_name, + col(Book.id).in_(book_ids_with_author), col(Book.cover_url).is_not(None), ) .order_by(col(Book.id)) @@ -544,7 +815,7 @@ def get_statistics( select(Book.id, Book.title, Book.reading_status, Book.cover_url) .where( Book.user_id == current_user.id, - Book.author == author_name, + col(Book.id).in_(book_ids_with_author), col(Book.cover_url).is_(None), ) .order_by(col(Book.id)) @@ -573,28 +844,36 @@ def get_statistics( average_rating = round(mean(rating_values), 2) if rating_values else None rated_books = [b for b in books if b.rating is not None] + rated_book_ids = [b.id for b in rated_books if b.id is not None] + rated_authors_map = load_authors_batch(session, rated_book_ids) def _rating_sort_key(book: Book) -> tuple[int, float]: assert book.rating is not None return (book.rating, -(book.date_added or datetime.min).timestamp()) + # Top rated: highest rating first; ties broken by newest-added first. top_rated_books = [] - for b in sorted(rated_books, key=_rating_sort_key): + for b in sorted(rated_books, key=lambda x: (-_rating_sort_key(x)[0], _rating_sort_key(x)[1])): assert b.id is not None assert b.rating is not None + author_names = rated_authors_map.get(b.id, []) top_rated_books.append( - TopRatedBook(book_id=b.id, title=b.title or "", author=b.author, rating=b.rating, reading_status=b.reading_status, cover_url=b.cover_url) + TopRatedBook(book_id=b.id, title=b.title or "", author=join_authors(author_names), authors=author_names, rating=b.rating, reading_status=b.reading_status, cover_url=b.cover_url) ) + # Worst rated: lowest rating first; ties broken by newest-added first. worst_rated_books = [] - for b in sorted(rated_books, key=lambda x: (-_rating_sort_key(x)[0], -_rating_sort_key(x)[1])): + for b in sorted(rated_books, key=_rating_sort_key): assert b.id is not None assert b.rating is not None + author_names = rated_authors_map.get(b.id, []) worst_rated_books.append( - TopRatedBook(book_id=b.id, title=b.title or "", author=b.author, rating=b.rating, reading_status=b.reading_status, cover_url=b.cover_url) + TopRatedBook(book_id=b.id, title=b.title or "", author=join_authors(author_names), authors=author_names, rating=b.rating, reading_status=b.reading_status, cover_url=b.cover_url) ) return StatisticsResponse( + total_books=len(books), + total_authors=total_authors, avg_books_per_month=avg_books_per_month, busiest_month=busiest_month, busiest_month_count=busiest_month_count, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 4d127e69..f2662452 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,11 +1,12 @@ """Pydantic / SQLModel request and response schemas for the API.""" -from typing import Optional +from typing import Optional, Any from datetime import datetime from enum import Enum from typing import Literal -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, field_validator, model_validator +import pydantic from sqlmodel import Field, SQLModel from sqlmodel._compat import SQLModelConfig @@ -39,9 +40,28 @@ class ReadingProgressLatest(SQLModel): class BookCreate(SQLModel): """Request body to create a new book.""" + + @model_validator(mode="before") + @classmethod + def require_author(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + author = data.get("author") + authors = data.get("authors") + has_author = bool( + (isinstance(author, str) and author.strip()) + or (isinstance(authors, list) and any(isinstance(a, str) and a.strip() for a in authors)) + ) + if not has_author: + raise ValueError( + "A book must have at least one author: provide either 'author' or 'authors'." + ) + return data + title: str subtitle: Optional[str] = None - author: str + author: Optional[str] = pydantic.Field(default=None, deprecated=True) + authors: Optional[list[str]] = None isbn: Optional[str] = None cover_url: Optional[str] = None publisher: Optional[str] = None @@ -62,7 +82,8 @@ class BookUpdate(SQLModel): """Request body to partially update a book.""" title: Optional[str] = None subtitle: Optional[str] = None - author: Optional[str] = None + author: Optional[str] = pydantic.Field(default=None, deprecated=True) + authors: Optional[list[str]] = None isbn: Optional[str] = None cover_url: Optional[str] = None publisher: Optional[str] = None @@ -83,7 +104,8 @@ class BookImportCandidate(SQLModel): """A book result from an external API, not yet persisted locally.""" title: str subtitle: Optional[str] = None - author: Optional[str] = None + author: Optional[str] = pydantic.Field(default=None, deprecated=True) + authors: Optional[list[str]] = None isbn: Optional[str] = None cover_url: Optional[str] = None publisher: Optional[str] = None @@ -129,7 +151,8 @@ class BookRead(SQLModel): id: int title: str subtitle: Optional[str] - author: Optional[str] + author: Optional[str] = pydantic.Field(deprecated=True) + authors: list[str] = [] isbn: Optional[str] cover_url: Optional[str] publisher: Optional[str] @@ -245,7 +268,8 @@ class TopRatedBook(SQLModel): """A book appearing in top/worst rated lists.""" book_id: int title: str - author: Optional[str] + author: Optional[str] = pydantic.Field(deprecated=True) + authors: list[str] = [] rating: int reading_status: ReadingStatus cover_url: Optional[str] @@ -253,6 +277,8 @@ class TopRatedBook(SQLModel): class StatisticsResponse(SQLModel): """Full statistics dashboard response.""" + total_books: int + total_authors: int avg_books_per_month: Optional[float] busiest_month: Optional[str] busiest_month_count: Optional[int] @@ -274,6 +300,32 @@ class StatisticsResponse(SQLModel): worst_rated_books: list[TopRatedBook] +class GoalType(str, Enum): + """Reading goal types tracked on the dashboard.""" + pages_per_day = "pages_per_day" + pages_per_month = "pages_per_month" + books_per_month = "books_per_month" + books_per_year = "books_per_year" + + +class GoalProgress(SQLModel): + """Progress toward a single reading goal (enabled goals only).""" + type: GoalType + target: int + current: int + reached: bool + + +class GamificationResponse(SQLModel): + """Dashboard gamification data — reading streaks and goal progress.""" + enabled: bool + current_streak: int + longest_streak: int + longest_streak_start: Optional[str] = None + longest_streak_end: Optional[str] = None + goals: list[GoalProgress] + + class UserLogin(SQLModel): """Login request body.""" email: str @@ -352,6 +404,15 @@ class UserSettingsRead(SQLModel): timezone: str theme: str custom_theme: Optional[str] = None + goal_pages_per_day_enabled: bool + goal_pages_per_day: int + goal_pages_per_month_enabled: bool + goal_pages_per_month: int + goal_books_per_month_enabled: bool + goal_books_per_month: int + goal_books_per_year_enabled: bool + goal_books_per_year: int + gamification_enabled: bool class UserSettingsUpdate(SQLModel): @@ -360,6 +421,15 @@ class UserSettingsUpdate(SQLModel): timezone: Optional[str] = None theme: Optional[str] = None custom_theme: Optional[str] = None + goal_pages_per_day_enabled: Optional[bool] = None + goal_pages_per_day: Optional[int] = Field(default=None, ge=1) + goal_pages_per_month_enabled: Optional[bool] = None + goal_pages_per_month: Optional[int] = Field(default=None, ge=1) + goal_books_per_month_enabled: Optional[bool] = None + goal_books_per_month: Optional[int] = Field(default=None, ge=1) + goal_books_per_year_enabled: Optional[bool] = None + goal_books_per_year: Optional[int] = Field(default=None, ge=1) + gamification_enabled: Optional[bool] = None @field_validator('theme') @classmethod @@ -385,6 +455,7 @@ class DataResetDeleted(SQLModel): """Counts of deleted items after a data reset.""" books: int tags: int + authors: int progress_entries: int @@ -481,7 +552,8 @@ class HygieneMissingBook(SQLModel): """A single book in the data-hygiene listing with its missing fields annotated.""" id: int title: str - author: str | None + author: str | None = pydantic.Field(deprecated=True) + authors: list[str] = [] isbn: str | None publisher: str | None published_year: int | None @@ -613,8 +685,8 @@ class DataImportValidateResponse(SQLModel): class DataImportPreviewRow(SQLModel): """A single row in the import preview.""" row_number: int - source: dict[str, str] - transformed: dict[str, Optional[str]] + source: dict[str, Any] + transformed: dict[str, Any] errors: list[str] diff --git a/backend/app/services/authors.py b/backend/app/services/authors.py new file mode 100644 index 00000000..396e4795 --- /dev/null +++ b/backend/app/services/authors.py @@ -0,0 +1,232 @@ +"""Author parsing, synchronization, and query helpers. + +Authors are stored per-user in the ``author`` table and linked to books through +the ``book_author`` relation table, mirroring how tags work. The public API keeps +an ``author`` joined-string field for backward compatibility alongside the new +``authors`` list field. +""" + +import re + +from sqlmodel import Session, col, select + +from app.models import Author, Book, BookAuthor +from app.time_utils import utcnow + + +def normalize_author_name(name: str) -> str | None: + """Trim whitespace and collapse internal whitespace in an author name.""" + cleaned = " ".join(name.strip().split()) + return cleaned or None + + +def normalize_author_list(names: list[str] | None) -> list[str]: + """Normalize a list of author names from the API or UI. + + Trims whitespace, collapses internal whitespace, and deduplicates + case-insensitively while preserving the first-cased spelling seen. + """ + if not names: + return [] + seen: set[str] = set() + result: list[str] = [] + for name in names: + cleaned = normalize_author_name(name) + if cleaned is None: + continue + key = cleaned.lower() + if key in seen: + continue + seen.add(key) + result.append(cleaned) + return result + + +def parse_legacy_author(raw: str | None) -> list[str]: + """Parse a legacy comma-separated author string (for BookCreate/Update.author). + + Mirrors tag parsing: split on commas, trim, collapse whitespace, deduplicate. + """ + if not raw: + return [] + seen: set[str] = set() + parsed: list[str] = [] + for piece in raw.split(","): + cleaned = normalize_author_name(piece) + if cleaned is None: + continue + key = cleaned.lower() + if key in seen: + continue + seen.add(key) + parsed.append(cleaned) + return parsed + + +def parse_authors(raw: str | list[str] | None) -> list[str]: + """Normalize an author value from a file import. + + A list contributes one author per entry. A string is passed through + ``split_author_string``: it stays a single author unless it contains a + ``;``, `` & ``, or `` and `` separator (how the CSV export writes the + ``authors`` column), so it can round-trip. Commas inside a name like + ``"Asimov, Isaac"`` are always preserved. The result is deduplicated + case-insensitively while preserving the first spelling seen. + """ + if raw is None: + return [] + pieces = split_author_string(raw) if isinstance(raw, str) else raw + seen: set[str] = set() + result: list[str] = [] + for piece in pieces: + name = normalize_author_name(piece) + if name is None: + continue + key = name.lower() + if key in seen: + continue + seen.add(key) + result.append(name) + return result + + +def resolve_authors_payload( + author: str | None = None, + authors: list[str] | None = None, +) -> list[str] | None: + """Resolve the hybrid create/update payload. + + * If ``authors`` is provided (including ``[]``), use it. + * Otherwise fall back to parsing the legacy ``author`` string. + * If neither is provided, return None so the caller can treat it as + "not provided" (for updates) or "empty" (for creates). + """ + if authors is not None: + return normalize_author_list(authors) + if author is not None: + return parse_legacy_author(author) + return None + + +# Conservative separator regex used only when an external source returns a +# single delimited string. Never splits on commas. +_AUTHOR_SEPARATOR_RE = re.compile( + r"\s*;\s*|\s+&\s+|\s+and\s+", + flags=re.IGNORECASE, +) + + +def split_author_string(value: str | None) -> list[str]: + """Split a single author string from an external source. + + Only semicolons, ``" & "``, and ``" and "`` are treated as separators, so + names containing commas (e.g. ``"Asimov, Isaac"``) are preserved. + If no known separator is found, the whole string is returned as one author. + """ + if not value: + return [] + if not _AUTHOR_SEPARATOR_RE.search(value): + name = normalize_author_name(value) + return [name] if name else [] + parts = _AUTHOR_SEPARATOR_RE.split(value) + return [name for name in (normalize_author_name(p) for p in parts) if name] + + +def sync_book_authors( + session: Session, user_id: int, book_id: int, names: list[str] | None +) -> None: + """Set the authors for a book to *names*, creating Author rows as needed.""" + parsed = normalize_author_list(names) + + existing_links = list( + session.exec(select(BookAuthor).where(BookAuthor.book_id == book_id)).all() + ) + existing_author_ids = {link.author_id for link in existing_links} + + if not parsed: + for link in existing_links: + session.delete(link) + return + + existing_authors = list( + session.exec( + select(Author).where(Author.user_id == user_id, col(Author.name).in_(parsed)) + ).all() + ) + name_to_author = {author.name: author for author in existing_authors} + + for name in parsed: + if name in name_to_author: + continue + author = Author(user_id=user_id, name=name, created_at=utcnow()) + session.add(author) + session.flush() + name_to_author[name] = author + + target_ids: set[int] = set() + for name in parsed: + author_id = name_to_author[name].id + if author_id is not None: + target_ids.add(author_id) + + for author_id in target_ids - existing_author_ids: + session.add(BookAuthor(book_id=book_id, author_id=author_id)) + + for link in existing_links: + if link.author_id not in target_ids: + session.delete(link) + + +def cleanup_orphan_authors(session: Session, user_id: int) -> None: + """Delete authors of this user that are no longer linked to any book.""" + linked_ids = set( + session.exec( + select(BookAuthor.author_id).where( + col(BookAuthor.author_id).in_( + select(Author.id).where(Author.user_id == user_id) + ) + ) + ).all() + ) + authors = list(session.exec(select(Author).where(Author.user_id == user_id)).all()) + for author in authors: + if author.id not in linked_ids: + session.delete(author) + + +def authors_list_for_book(session: Session, book_id: int | None) -> list[str]: + """Return ordered author names for a book.""" + if book_id is None: + return [] + names = list( + session.exec( + select(Author.name) + .join(BookAuthor, col(BookAuthor.author_id) == col(Author.id)) + .where(BookAuthor.book_id == book_id) + .order_by(col(Author.name).asc()) + ).all() + ) + return names + + +def load_authors_batch(session: Session, book_ids: list[int]) -> dict[int, list[str]]: + """Batch-load author lists for many book IDs.""" + if not book_ids: + return {} + rows = session.exec( + select(BookAuthor.book_id, Author.name) + .join(Author, col(Author.id) == col(BookAuthor.author_id)) + .where(col(BookAuthor.book_id).in_(book_ids)) + .order_by(col(BookAuthor.book_id), col(Author.name).asc()) + ).all() + result: dict[int, list[str]] = {} + for book_id, name in rows: + result.setdefault(book_id, []).append(name) + return {bid: names for bid, names in result.items()} + + +def join_authors(names: list[str] | None) -> str | None: + """Join author names for the legacy ``author`` response field.""" + if not names: + return None + return ", ".join(names) \ No newline at end of file diff --git a/backend/app/services/book_import.py b/backend/app/services/book_import.py index d1c34d16..13b3be53 100644 --- a/backend/app/services/book_import.py +++ b/backend/app/services/book_import.py @@ -364,7 +364,7 @@ async def _search_open_library( def map_open_library(doc: dict) -> BookImportCandidate: """Map a single Open Library search doc to BookImportCandidate.""" # Authors: list of strings - authors = doc.get("author_name") or [] + authors = [a for a in (doc.get("author_name") or []) if a] author = ", ".join(authors) if authors else None # ISBN: first entry of the list, prefer ISBN-13 (length 13) @@ -391,6 +391,7 @@ def map_open_library(doc: dict) -> BookImportCandidate: title=doc["title"], subtitle=doc.get("subtitle") or None, author=author, + authors=authors, isbn=isbn, cover_url=cover_url, publisher=publisher, @@ -561,7 +562,7 @@ def map_google_books(item: dict) -> BookImportCandidate: vi = item.get("volumeInfo", {}) # Authors - authors: list[str] = vi.get("authors") or [] + authors: list[str] = [a for a in (vi.get("authors") or []) if a] author = ", ".join(authors) if authors else None # ISBN: prefer ISBN_13 @@ -596,6 +597,7 @@ def map_google_books(item: dict) -> BookImportCandidate: title=vi["title"], subtitle=vi.get("subtitle") or None, author=author, + authors=authors, isbn=isbn, cover_url=cover_url, publisher=vi.get("publisher"), @@ -770,12 +772,12 @@ def map_hardcover(edition: dict) -> BookImportCandidate | None: return None contributions = edition.get("contributions") or [] - author = None + authors: list[str] = [] for c in contributions: - author_name = c.get("author", {}).get("name") + author_name = (c.get("author") or {}).get("name") if author_name: - author = author_name - break + authors.append(author_name) + author = ", ".join(authors) if authors else None isbn = edition.get("isbn_13") or None @@ -811,6 +813,7 @@ def map_hardcover(edition: dict) -> BookImportCandidate | None: title=title, subtitle=edition.get("subtitle") or None, author=author, + authors=authors, isbn=isbn, cover_url=cover_url, publisher=publisher, diff --git a/backend/app/services/data_export.py b/backend/app/services/data_export.py index c6f047f2..18e97206 100644 --- a/backend/app/services/data_export.py +++ b/backend/app/services/data_export.py @@ -12,14 +12,16 @@ from app._build_info import __git_sha__, __version__ from app.models import Book, BookTag, ReadingProgress, Tag, User +from app.services.authors import authors_list_for_book from app.time_utils import utcnow from app.services.cover_storage import local_cover_filename, resolve_cover_path -from app.services.tags import tags_text_for_book +from app.services.tags import tags_list_for_book BOOK_CSV_FIELDS: list[str] = [ "title", "subtitle", "author", + "authors", "isbn", "publisher", "published_year", @@ -48,18 +50,31 @@ def _serialize_datetime(value: datetime | None) -> str | None: return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") -def _book_to_dict(session: Session, book: Book) -> dict: - """Convert a Book model to a flat export dict.""" +def _book_to_dict(session: Session, book: Book, export_format: str) -> dict: + """Convert a Book model to a flat export dict. + + JSON exports mirror the API shape: ``author`` is the joined string (using + ``; `` so it round-trips through the adaptive import), ``authors`` is the + name list, and ``tags`` is a list of tag names. CSV has no list type: both + the ``author`` and ``authors`` columns use ``; `` (so they round-trip + through ``parse_authors``), and ``tags`` is a comma-separated string. + """ + authors = authors_list_for_book(session, book.id) + tags = tags_list_for_book(session, book.id) + author_value = "; ".join(authors) if authors else None + tags_value = tags if export_format == "json" else (", ".join(tags) if tags else None) + authors_value = authors if export_format == "json" else ("; ".join(authors) if authors else None) return { "title": book.title, "subtitle": book.subtitle, - "author": book.author, + "author": author_value, + "authors": authors_value, "isbn": book.isbn, "publisher": book.publisher, "published_year": book.published_year, "page_count": book.page_count, "language": book.language, - "tags": tags_text_for_book(session, book.id) if book.id else None, + "tags": tags_value, "notes": book.notes, "blurb": book.blurb, "rating": book.rating, @@ -150,7 +165,7 @@ def build_export_zip( book_titles = {book.id: book.title for book in books if book.id is not None} - books_rows = [_book_to_dict(session, book) for book in books] + books_rows = [_book_to_dict(session, book, export_format) for book in books] progress_rows = [_progress_to_dict(entry, book_titles) for entry in progress_entries] tag_rows = [_tag_to_dict(tag, tag_counts) for tag in tags] diff --git a/backend/app/services/data_import.py b/backend/app/services/data_import.py index 1b353cd1..89144b49 100644 --- a/backend/app/services/data_import.py +++ b/backend/app/services/data_import.py @@ -20,6 +20,7 @@ logger = logging.getLogger(__name__) from app.time_utils import utcnow +from app.services.authors import parse_authors, sync_book_authors from app.services.cover_storage import download_cover from app.services.tags import sync_book_tags from app.services.isbn_utils import normalize_isbn @@ -27,7 +28,7 @@ BOOK_IMPORT_FIELDS: list[str] = [ "title", "subtitle", - "author", + "authors", "isbn", "publisher", "published_year", @@ -39,6 +40,7 @@ "rating", "reading_status", "acquisition_status", + "date_added", "date_started", "date_finished", "cover_url", @@ -50,9 +52,9 @@ "name": "title", "subtitle": "subtitle", "book subtitle": "subtitle", - "author": "author", - "authors": "author", - "author name": "author", + "author": "authors", + "authors": "authors", + "author name": "authors", "isbn": "isbn", "isbn13": "isbn", "isbn10": "isbn", @@ -79,6 +81,8 @@ "acquisition": "acquisition_status", "availability": "acquisition_status", "ownership": "acquisition_status", + "date added": "date_added", + "added": "date_added", "date started": "date_started", "started": "date_started", "date finished": "date_finished", @@ -128,23 +132,29 @@ def delete_parsed_upload(file_id: str, user_id: int) -> None: _temp_file_path(user_id, file_id).unlink(missing_ok=True) -def _to_flat_row(row: dict) -> dict[str, str | int | float | bool | None]: - """Flatten a row dict, rejecting nested values.""" - flat: dict[str, str | int | float | bool | None] = {} +def _to_flat_row(row: dict) -> dict[str, object]: + """Flatten a row dict, rejecting nested dict values. + + List values (e.g. a JSON ``authors`` array) are kept as-is so the adaptive + author handling can turn each entry into a separate author. + """ + flat: dict[str, object] = {} for key, value in row.items(): - if isinstance(value, (dict, list)): + if isinstance(value, dict): raise ValueError("error.importNestedValuesNotSupported") flat[str(key)] = value return flat -def parse_upload(content: bytes, filename: str, user_id: int) -> dict: +def parse_upload(content: bytes, filename: str, user_id: int, delimiter: str = ",") -> dict: """Parse an uploaded CSV or JSON file and persist the parsed result to disk. Args: content: Raw file bytes. filename: Original filename (used to detect format). user_id: Owner of the upload. + delimiter: Single-character field separator used for CSV files + (ignored for JSON). Returns: A dict with file_id, format, source_fields, sample_rows, and row_count. @@ -159,9 +169,11 @@ def parse_upload(content: bytes, filename: str, user_id: int) -> dict: lower = filename.lower() if lower.endswith(".csv"): + if len(delimiter) != 1: + raise ValueError("error.importInvalidDelimiter") parsed_format = "csv" text = content.decode("utf-8-sig") - reader = csv.DictReader(text.splitlines()) + reader = csv.DictReader(text.splitlines(), delimiter=delimiter) if not reader.fieldnames: raise ValueError("error.importMissingHeader") rows = [_to_flat_row(row) for row in reader] @@ -386,21 +398,39 @@ def _parse_reading_status(value: object) -> ReadingStatus: def _build_transform_cache( mapping: dict[str, "ImportFieldConfig"], -) -> dict[str, Callable[..., str]]: +) -> dict[str, Callable[..., Any]]: """Compile all transform expressions into a cache of callables.""" from app.services.transform_engine import compile_transform - cache: dict[str, Callable[..., str]] = {} + cache: dict[str, Callable[..., Any]] = {} for target, config in mapping.items(): if config.transform: cache[target] = compile_transform(config.transform) return cache +def canonicalize_mapping( + mapping: dict[str, ImportFieldConfig], +) -> dict[str, ImportFieldConfig]: + """Normalize a mapping dict, renaming the legacy ``author`` target to ``authors``. + + Both spellings refer to the same per-book author relation; saved mappings + created before the rename may still use ``author`` as the target key. If + both keys are present, the first occurrence in iteration order wins. + """ + result: dict[str, ImportFieldConfig] = {} + for target, config in mapping.items(): + canonical = "authors" if target == "author" else target + if canonical in result: + continue + result[canonical] = config + return result + + def _mapped_row( row: dict, mapping: dict[str, "ImportFieldConfig"], - transform_cache: dict[str, Callable[..., str]], + transform_cache: dict[str, Callable[..., Any]], context: dict[str, Any], errors: list[str] | None = None, ) -> dict: @@ -411,18 +441,35 @@ def _mapped_row( if not source: continue value = row.get(source, "") + if target == "authors" and isinstance(value, list): + # Adaptive authors handling: an array contributes one author per + # entry. Transforms are skipped for array values (they operate on + # scalars); apply them in the source file or use the string form. + mapped[target] = value + continue + if target == "tags" and isinstance(value, list): + # Adaptive tags handling: an array contributes one tag per entry. + # Like authors, transforms are skipped for array values. + mapped[target] = value + continue value_str = "" if value is None else str(value) if target in transform_cache: from app.services.transform_engine import TransformExecutionError, execute_transform try: - value_str = execute_transform( + transform_result = execute_transform( transform_cache[target], value_str, row, context ) except TransformExecutionError as exc: if errors is not None: errors.append(f"\x1f{target}\x1f{exc}") continue + if target in ("authors", "tags") and isinstance(transform_result, list): + # A transform may return a list (e.g. value.split(';')) for the + # adaptive authors/tags targets; keep it so each entry is used. + mapped[target] = transform_result + continue + value_str = str(transform_result) mapped[target] = value_str return mapped @@ -484,6 +531,7 @@ def validate_import( parsed = load_parsed_upload(file_id, user.id) rows = parsed.get("rows", []) source_fields = set(parsed.get("source_fields", [])) + mapping = canonicalize_mapping(mapping) warnings, errors = _validate_mapping(mapping, source_fields, require_acquisition_status) @@ -527,6 +575,12 @@ def validate_import( errors.append(f"Row {idx}: {exc}") continue + date_added: datetime | None = None + try: + date_added = _parse_datetime(row_data.get("date_added"), "date_added") + except ValueError as exc: + errors.append(f"Row {idx}: {exc}") + date_started: datetime | None = None try: date_started = _parse_datetime(row_data.get("date_started"), "date_started") @@ -549,6 +603,12 @@ def validate_import( "without a finish date the book will not count toward monthly statistics" ) + if date_added and date_finished and date_added > date_finished: + warnings.append( + f"Row {idx}: date_added is after date_finished; the book will appear " + "as added after it was finished" + ) + if create_progress_for_read and reading_status == ReadingStatus.read and not row_data.get("page_count"): warnings.append(f"Row {idx}: marked as 'read' but has no page count; will not create a progress entry") @@ -596,6 +656,7 @@ def preview_import( parsed = load_parsed_upload(file_id, user.id) rows = parsed.get("rows", []) source_fields = set(parsed.get("source_fields", [])) + mapping = canonicalize_mapping(mapping) _warnings, mapping_errors = _validate_mapping(mapping, source_fields, require_acquisition_status) if mapping_errors: @@ -633,6 +694,12 @@ def preview_import( except ValueError as exc: row_errors.append(str(exc)) + date_added: datetime | None = None + try: + date_added = _parse_datetime(row_data.get("date_added"), "date_added") + except ValueError as exc: + row_errors.append(str(exc)) + date_started: datetime | None = None try: date_started = _parse_datetime(row_data.get("date_started"), "date_started") @@ -648,16 +715,22 @@ def preview_import( if date_started and date_finished and date_started > date_finished: row_errors.append("date_started is after date_finished") + if date_added and date_finished and date_added > date_finished: + row_errors.append( + "date_added is after date_finished; the book will appear " + "as added after it was finished" + ) + if reading_status == ReadingStatus.read and not date_finished: row_errors.append( "Marked as 'read' but has no finished date; " "without a finish date the book will not count toward monthly statistics" ) - # Convert raw values to strings for display - source_display = {k: str(v) if v is not None else "" for k, v in row.items()} - # Convert transformed values to strings for display - transformed_display = {k: str(v) if v is not None else "" for k, v in row_data.items()} + # Keep list values (e.g. JSON `authors`/`tags` arrays) as lists so the + # preview renders them as JSON arrays; None is shown as an empty string. + source_display = {k: v if v is not None else "" for k, v in row.items()} + transformed_display = {k: v if v is not None else "" for k, v in row_data.items()} preview_rows.append({ "row_number": idx, @@ -704,6 +777,7 @@ async def execute_import( rollback_all = import_mode == "rollback_all" source_fields = set(parsed.get("source_fields", [])) + mapping = canonicalize_mapping(mapping) _warnings, mapping_errors = _validate_mapping(mapping, source_fields, require_acquisition_status) if mapping_errors: yield {"event": "error", "message": "; ".join(mapping_errors)} @@ -738,6 +812,12 @@ async def execute_import( None if row_data.get("language") is None else str(row_data.get("language")) ) date_errors: list[str] = [] + date_added: datetime | None = None + try: + date_added = _parse_datetime(row_data.get("date_added"), "date_added") + except ValueError as exc: + date_errors.append(str(exc)) + date_started: datetime | None = None try: date_started = _parse_datetime(row_data.get("date_started"), "date_started") @@ -756,6 +836,12 @@ async def execute_import( if date_started and date_finished and date_started > date_finished: raise ValueError("date_started is after date_finished") + if date_added and date_finished and date_added > date_finished: + raise ValueError( + "date_added is after date_finished; the book would appear " + "as added after it was finished" + ) + if reading_status == ReadingStatus.read and not date_finished: raise ValueError("Marked as 'read' but has no finished date") @@ -772,7 +858,6 @@ async def execute_import( book = Book( title=title, subtitle=None if row_data.get("subtitle") in (None, "") else str(row_data.get("subtitle")), - author=None if row_data.get("author") in (None, "") else str(row_data.get("author")), # ty: ignore[invalid-argument-type] isbn=None if row_data.get("isbn") in (None, "") else str(row_data.get("isbn")), cover_url=cover_url, publisher=None if row_data.get("publisher") in (None, "") else str(row_data.get("publisher")), @@ -784,6 +869,7 @@ async def execute_import( rating=rating, reading_status=reading_status, acquisition_status=acquisition_status, + date_added=date_added or utcnow(), date_started=date_started, date_finished=date_finished, user_id=user.id, @@ -792,6 +878,13 @@ async def execute_import( session.flush() assert book.id is not None + sync_book_authors( + session, + user.id, + book.id, + parse_authors(row_data.get("authors")), + ) + if create_progress_for_read and reading_status == ReadingStatus.read and page_count is not None and date_finished is not None: log_date = date_finished if log_date.tzinfo is None: @@ -808,7 +901,7 @@ async def execute_import( session, user.id, book.id, - None if row_data.get("tags") in (None, "") else str(row_data.get("tags")), + None if row_data.get("tags") in (None, "") else row_data.get("tags"), ) if not rollback_all: @@ -859,7 +952,7 @@ async def execute_import( "mapping": { "title": {"source": "Title", "transform": None}, "subtitle": {"source": "", "transform": None}, - "author": {"source": "Author", "transform": None}, + "authors": {"source": "Author", "transform": None}, "isbn": {"source": "ISBN13", "transform": "value.replace('=', '').replace('\"', '').strip() if value else None"}, "publisher": {"source": "Publisher", "transform": None}, "published_year": { diff --git a/backend/app/services/search.py b/backend/app/services/search.py new file mode 100644 index 00000000..cc1a757a --- /dev/null +++ b/backend/app/services/search.py @@ -0,0 +1,224 @@ +"""Field-specific search query parsing and SQL filter building. + +Search queries may contain field prefixes of the form ``:value`` (or +``:"multi word value"``) to restrict a term to a single field. Any search +part may be negated by prefixing it with ``-``. Unprefixed text is collapsed +into a single phrase that is matched across the default search fields, +preserving the previous behaviour. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + +import sqlalchemy as sa +from sqlmodel import col, or_, select + +from app.models import AcquisitionStatus, Author, Book, BookAuthor, BookTag, Tag + +# Fields that can be targeted with a prefix. The keys are the canonical, +# always-English prefix names; the values are the book model columns. +FIELD_COLUMNS: dict[str, Any] = { + "title": Book.title, + "publisher": Book.publisher, + "language": Book.language, + "notes": Book.notes, + "description": Book.blurb, +} + +# Possession is a special case: it maps to an exact enum comparison. +POSSESSION_PREFIX = "possession" +TAG_PREFIX = "tag" +AUTHOR_PREFIX = "author" + +SUPPORTED_PREFIXES: frozenset[str] = frozenset( + [*FIELD_COLUMNS.keys(), POSSESSION_PREFIX, TAG_PREFIX, AUTHOR_PREFIX] +) + +# Default fields searched by an unprefixed term (unchanged from the previous +# single-pattern search, minus the removed Book.author column). +DEFAULT_SEARCH_COLUMNS: tuple[str, ...] = ("title", "subtitle", "blurb") + +# ``:value`` — value is either a quoted string or a non-space token. +_FIELD_TERM_RE = re.compile(r"^([a-zA-Z_]+):(\"(?:\\.|[^\"\\])*\"|\S+)") +_QUOTED_PHRASE_RE = re.compile(r'^"((?:\\.|[^"\\])*)"') +_TOKEN_RE = re.compile(r"^\S+") + + +@dataclass(frozen=True) +class SearchTerm: + """A single parsed search term. + + ``field`` is ``None`` for unprefixed terms. ``negated`` indicates a leading + ``-`` on the term. + """ + + field: str | None + value: str + negated: bool = False + + +def _clean_value(raw: str) -> str: + """Strip surrounding quotes from a raw value and unescape inner quotes.""" + if len(raw) >= 2 and raw.startswith('"') and raw.endswith('"'): + raw = raw[1:-1] + elif raw.startswith('"'): + # Forgiving handling for unclosed quotes: strip the leading quote. + raw = raw[1:] + # Unescape backslashes first so an escaped quote after an escaped backslash + # is not consumed by the wrong pair. + return raw.replace("\\\\", "\\").replace('\\"', '"') + + +def _escape_like(value: str) -> str: + """Escape LIKE wildcards so user input is matched literally.""" + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + +def parse_search_query(query: str) -> list[SearchTerm]: + """Split a search query into field-specific and unprefixed terms. + + Unknown prefixes, malformed quotes, and plain tokens are kept as unprefixed + terms so the previous cross-field search behaviour is preserved. + """ + terms: list[SearchTerm] = [] + rest = query.strip() + while rest: + negated = False + if rest.startswith("-"): + negated = True + rest = rest[1:].lstrip() + if not rest: + break + + match = _FIELD_TERM_RE.match(rest) + if match: + prefix, raw_value = match.group(1), match.group(2) + if prefix.lower() in SUPPORTED_PREFIXES: + value = _clean_value(raw_value) + if value: + terms.append(SearchTerm(field=prefix.lower(), value=value, negated=negated)) + rest = rest[match.end():].lstrip() + continue + + match = _QUOTED_PHRASE_RE.match(rest) + if match: + value = _clean_value(match.group(1)) + if value: + terms.append(SearchTerm(field=None, value=value, negated=negated)) + rest = rest[match.end():].lstrip() + continue + + match = _TOKEN_RE.match(rest) + if match: + token = match.group(0) + if token: + terms.append(SearchTerm(field=None, value=token, negated=negated)) + rest = rest[match.end():].lstrip() + continue + + # Unrecognised leading character — skip it and continue. + rest = rest[1:].lstrip() + + return terms + + +def _ilike(column: Any, value: str) -> Any: + """Case-insensitive substring match that never yields NULL. + + ``NOT`` over ``LIKE`` on a NULL column produces NULL, which excludes the row. + Coalescing to false keeps NULLable fields (subtitle, blurb, publisher, …) + behaving as "no match" under negation. LIKE wildcards in the value are + escaped so user input is matched literally. + """ + escaped = _escape_like(value) + return sa.func.coalesce(col(column).ilike(f"%{escaped}%", escape="\\"), sa.false()) + + +def _tag_condition(value: str, user_id: int) -> Any: + """Return a condition matching books that have a tag containing *value*.""" + escaped = _escape_like(value) + matching_tag_book_ids = ( + select(BookTag.book_id) + .join(Tag, col(Tag.id) == BookTag.tag_id) + .where(Tag.user_id == user_id, col(Tag.name).ilike(f"%{escaped}%", escape="\\")) + ) + return col(Book.id).in_(matching_tag_book_ids) + + +def _author_condition(value: str, user_id: int) -> Any: + """Return a condition matching books that have an author containing *value*.""" + escaped = _escape_like(value) + matching_author_book_ids = ( + select(BookAuthor.book_id) + .join(Author, col(Author.id) == BookAuthor.author_id) + .where(Author.user_id == user_id, col(Author.name).ilike(f"%{escaped}%", escape="\\")) + ) + return col(Book.id).in_(matching_author_book_ids) + + +def _unprefixed_condition(value: str, user_id: int) -> Any: + """Build the cross-field substring condition for an unprefixed term.""" + return or_( + *[_ilike(getattr(Book, column), value) for column in DEFAULT_SEARCH_COLUMNS], + _tag_condition(value, user_id), + _author_condition(value, user_id), + ) + + +def _possession_condition(value: str) -> Any | None: + """Build the exact acquisition-status condition, or ``None`` if invalid.""" + normalized = value.strip().lower().replace(" ", "_") + try: + status = AcquisitionStatus(normalized) + except ValueError: + return None + return Book.acquisition_status == status + + +def _field_condition(field: str, value: str, user_id: int) -> Any | None: + """Build the condition for a single field-specific term.""" + if field == POSSESSION_PREFIX: + return _possession_condition(value) + if field == TAG_PREFIX: + return _tag_condition(value, user_id) + if field == AUTHOR_PREFIX: + return _author_condition(value, user_id) + column = FIELD_COLUMNS[field] + return _ilike(column, value) + + +def apply_search_filter(statement: Any, query: str, user_id: int) -> Any: + """Return a book SELECT statement restricted by the parsed search query. + + All terms are combined with AND. Negated terms become ``AND NOT(condition)``. + Positive unprefixed text is collapsed into a single cross-field phrase. + """ + terms = parse_search_query(query) + + conditions: list[Any] = [] + + positive_unprefixed = [term.value for term in terms if term.field is None and not term.negated] + if positive_unprefixed: + conditions.append(_unprefixed_condition(" ".join(positive_unprefixed), user_id)) + + for term in terms: + if term.field is None: + if term.negated: + conditions.append(sa.not_(_unprefixed_condition(term.value, user_id))) + continue + + condition = _field_condition(term.field, term.value, user_id) + if condition is None: + # Invalid possession value: positive yields no rows, negated is a no-op. + conditions.append(sa.false() if not term.negated else sa.true()) + elif term.negated: + conditions.append(sa.not_(condition)) + else: + conditions.append(condition) + + if conditions: + return statement.where(sa.and_(*conditions)) + return statement \ No newline at end of file diff --git a/backend/app/services/tags.py b/backend/app/services/tags.py index 68c60a9c..85eb353c 100644 --- a/backend/app/services/tags.py +++ b/backend/app/services/tags.py @@ -6,14 +6,15 @@ from app.models import Book, BookTag, Tag from app.schemas import BookRead +from app.services.authors import authors_list_for_book, join_authors from app.time_utils import utcnow -def parse_tags(raw_tags: str | None) -> list[str]: - """Parse and deduplicate a comma-separated tag string. +def parse_tags(raw_tags: str | list[str] | None) -> list[str]: + """Parse and deduplicate tag names. - Args: - raw_tags: Comma-separated tag names or None. + Accepts a comma-separated string (legacy/CSV) or a list of names (JSON + export/import). A list contributes one tag per entry. Returns: A list of normalized, unique tag name strings. @@ -21,10 +22,12 @@ def parse_tags(raw_tags: str | None) -> list[str]: if not raw_tags: return [] + pieces: list[str] = raw_tags if isinstance(raw_tags, list) else raw_tags.split(",") + seen: set[str] = set() parsed: list[str] = [] - for piece in raw_tags.split(","): - normalized = " ".join(piece.strip().split()) + for piece in pieces: + normalized = " ".join(str(piece).strip().split()) if not normalized: continue key = normalized.lower() @@ -35,7 +38,9 @@ def parse_tags(raw_tags: str | None) -> list[str]: return parsed -def sync_book_tags(session: Session, user_id: int, book_id: int, raw_tags: str | None) -> None: +def sync_book_tags( + session: Session, user_id: int, book_id: int, raw_tags: str | list[str] | None +) -> None: """Synchronize the tag associations for a book to match *raw_tags*. Creates new tags as needed and removes stale BookTag links. @@ -44,7 +49,7 @@ def sync_book_tags(session: Session, user_id: int, book_id: int, raw_tags: str | session: Active database session. user_id: Owner of the tags. book_id: Target book. - raw_tags: Comma-separated tag names or None (clears all tags). + raw_tags: Comma-separated tag names, a list of names, or None (clears all tags). """ parsed = parse_tags(raw_tags) @@ -92,9 +97,11 @@ def cleanup_orphan_tags(session: Session, user_id: int) -> None: session.delete(tag) -def tags_text_for_book(session: Session, book_id: int) -> str | None: - """Return a comma-separated tag string for a given book, or None.""" - names = list( +def tags_list_for_book(session: Session, book_id: int | None) -> list[str]: + """Return the sorted tag names for a given book, or an empty list.""" + if book_id is None: + return [] + return list( session.exec( select(Tag.name) .join(BookTag, col(BookTag.tag_id) == col(Tag.id)) @@ -102,6 +109,11 @@ def tags_text_for_book(session: Session, book_id: int) -> str | None: .order_by(col(Tag.name).asc()) ).all() ) + + +def tags_text_for_book(session: Session, book_id: int) -> str | None: + """Return a comma-separated tag string for a given book, or None.""" + names = tags_list_for_book(session, book_id) if not names: return None return ", ".join(names) @@ -131,4 +143,7 @@ def build_book_read(session: Session, book: Book) -> BookRead: payload = book.model_dump() payload.pop("user_id", None) payload["tags"] = tags_text_for_book(session, book.id) if book.id is not None else None + authors = authors_list_for_book(session, book.id) + payload["authors"] = authors + payload["author"] = join_authors(authors) return BookRead.model_validate(payload) diff --git a/backend/app/services/transform_engine.py b/backend/app/services/transform_engine.py index f24a89d1..a6c9a3e1 100644 --- a/backend/app/services/transform_engine.py +++ b/backend/app/services/transform_engine.py @@ -133,7 +133,7 @@ def _validate_ast(source: str) -> list[str]: return errors -def compile_transform(code: str) -> Callable[..., str]: +def compile_transform(code: str) -> Callable[..., Any]: """Compile a Python code block into a restricted callable. The user's code is wrapped in a function definition: @@ -196,17 +196,23 @@ def execute_transform( value: str, row: dict[str, str], context: dict[str, Any], -) -> str: - """Execute a compiled transform and enforce string return.""" +) -> Any: + """Execute a compiled transform and return its result. + + ``None`` results are coerced to the empty string. A ``list`` result is + passed through untouched so transforms on the adaptive ``authors``/``tags`` + targets can return an array (e.g. ``value.split(';')``); every other + non-string value is stringified, as before. + """ try: result = fn(value=value, row=row, context=context) except Exception as exc: raise TransformExecutionError(str(exc)) from exc if result is None: return "" - if not isinstance(result, str): - return str(result) - return result + if isinstance(result, list): + return result + return str(result) def validate_transform(code: str) -> list[str]: diff --git a/backend/app/services/user_deletion.py b/backend/app/services/user_deletion.py index 0634ac48..9cd11067 100644 --- a/backend/app/services/user_deletion.py +++ b/backend/app/services/user_deletion.py @@ -6,7 +6,7 @@ from fastapi import HTTPException, status from sqlmodel import Session, col, func, select -from app.models import ApiKey, Book, BookTag, OidcLink, ReadingProgress, Tag, User, UserRole, UserSettings +from app.models import ApiKey, Author, Book, BookAuthor, BookTag, OidcLink, ReadingProgress, Tag, User, UserRole, UserSettings from app.time_utils import utcnow from app.services.cover_storage import delete_cover_file, local_cover_filename @@ -16,6 +16,7 @@ class ReadingDataDeletionCounts: """Counts of items deleted during a reading-data or account deletion.""" books: int tags: int + authors: int progress_entries: int @@ -47,6 +48,9 @@ def delete_user_reading_data(session: Session, user_id: int, covers_dir: str) -> tags_count = session.exec( select(func.count()).select_from(Tag).where(Tag.user_id == user_id) ).one() + authors_count = session.exec( + select(func.count()).select_from(Author).where(Author.user_id == user_id) + ).one() if book_ids: for cover_url in {book.cover_url for book in user_books if book.cover_url}: @@ -62,18 +66,25 @@ def delete_user_reading_data(session: Session, user_id: int, covers_dir: str) -> for link in session.exec(select(BookTag).where(col(BookTag.book_id).in_(book_ids))).all(): session.delete(link) + for link in session.exec(select(BookAuthor).where(col(BookAuthor.book_id).in_(book_ids))).all(): + session.delete(link) + for entry in session.exec(select(ReadingProgress).where(ReadingProgress.user_id == user_id)).all(): session.delete(entry) for tag in session.exec(select(Tag).where(Tag.user_id == user_id)).all(): session.delete(tag) + for author in session.exec(select(Author).where(Author.user_id == user_id)).all(): + session.delete(author) + for book in user_books: session.delete(book) return ReadingDataDeletionCounts( books=len(user_books), tags=tags_count, + authors=authors_count, progress_entries=progress_count, ) diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py index 6ef2d0d4..658550e1 100644 --- a/backend/tests/test_admin.py +++ b/backend/tests/test_admin.py @@ -208,7 +208,7 @@ def test_admin_restore_success(admin_client_with_file_db: tuple[TestClient, str] # 2. Modify the database (add a new book) conn = sqlite3.connect(db_path) - conn.execute("INSERT INTO book (title, author, page_count, user_id, reading_status, acquisition_status) VALUES ('New Book', '', 0, 1, 'read', 'owned')") + conn.execute("INSERT INTO book (title, page_count, user_id, reading_status, acquisition_status) VALUES ('New Book', 0, 1, 'read', 'owned')") conn.commit() row = conn.execute("SELECT COUNT(*) FROM book").fetchone() assert row[0] == 2 diff --git a/backend/tests/test_authors.py b/backend/tests/test_authors.py new file mode 100644 index 00000000..ae4b10c7 --- /dev/null +++ b/backend/tests/test_authors.py @@ -0,0 +1,193 @@ +"""Tests for the author parsing and relation helpers.""" + +from sqlmodel import Session, col, select + +from app.models import Author, BookAuthor +from app.services.authors import ( + authors_list_for_book, + join_authors, + normalize_author_list, + normalize_author_name, + parse_authors, + parse_legacy_author, + resolve_authors_payload, + split_author_string, + sync_book_authors, +) + + +# ── normalization ────────────────────────────────────────────────────────────── + +def test_normalize_author_name_collapses_whitespace() -> None: + assert normalize_author_name(" Isaac Asimov ") == "Isaac Asimov" + + +def test_normalize_author_name_empty_returns_none() -> None: + assert normalize_author_name(" ") is None + + +def test_normalize_author_list_dedupes_case_insensitively() -> None: + assert normalize_author_list(["Isaac Asimov", "isaac asimov", "Robert A. Heinlein"]) == [ + "Isaac Asimov", + "Robert A. Heinlein", + ] + + +def test_normalize_author_list_drops_blank_entries() -> None: + assert normalize_author_list([" ", "Asimov, Isaac"]) == ["Asimov, Isaac"] + + +# ── parsing ─────────────────────────────────────────────────────────────────── + +def test_parse_legacy_author_splits_on_commas() -> None: + assert parse_legacy_author("Isaac Asimov, Robert A. Heinlein") == [ + "Isaac Asimov", + "Robert A. Heinlein", + ] + + +def test_parse_legacy_author_blank_pieces_skipped() -> None: + assert parse_legacy_author("Isaac Asimov, , Robert A. Heinlein") == [ + "Isaac Asimov", + "Robert A. Heinlein", + ] + + +def test_parse_authors_string_is_single_author() -> None: + """A file-import string without a separator becomes one author.""" + assert parse_authors("Asimov, Isaac") == ["Asimov, Isaac"] + assert parse_authors("Frank Herbert") == ["Frank Herbert"] + + +def test_parse_authors_string_splits_on_semicolon() -> None: + """Strings may encode several authors with `;` (CSV export round-trip).""" + assert parse_authors("Frank Herbert; Brian Herbert") == ["Frank Herbert", "Brian Herbert"] + + +def test_parse_authors_list_is_one_per_entry() -> None: + assert parse_authors(["Frank Herbert", "Brian Herbert"]) == [ + "Frank Herbert", + "Brian Herbert", + ] + + +def test_parse_authors_none_returns_empty() -> None: + assert parse_authors(None) == [] + + +# ── hybrid payload resolution ───────────────────────────────────────────────── + +def test_resolve_authors_payload_authors_takes_precedence() -> None: + assert resolve_authors_payload(author="A, B", authors=["C", "D"]) == ["C", "D"] + + +def test_resolve_authors_payload_empty_list_clears() -> None: + assert resolve_authors_payload(author="A", authors=[]) == [] + + +def test_resolve_authors_payload_falls_back_to_legacy() -> None: + assert resolve_authors_payload(author="A, B") == ["A", "B"] + + +def test_resolve_authors_payload_none_means_not_provided() -> None: + assert resolve_authors_payload() is None + + +# ── external splitter ───────────────────────────────────────────────────────── + +def test_split_author_string_preserves_comma_names() -> None: + assert split_author_string("Asimov, Isaac") == ["Asimov, Isaac"] + + +def test_split_author_string_splits_on_semicolon() -> None: + assert split_author_string("Asimov, Isaac; Clarke, Arthur") == [ + "Asimov, Isaac", + "Clarke, Arthur", + ] + + +def test_split_author_string_splits_on_ampersand() -> None: + assert split_author_string("Frank Herbert & Brian Herbert") == [ + "Frank Herbert", + "Brian Herbert", + ] + + +def test_split_author_string_splits_on_and() -> None: + assert split_author_string("Terry Pratchett and Neil Gaiman") == [ + "Terry Pratchett", + "Neil Gaiman", + ] + + +def test_split_author_string_empty_returns_empty() -> None: + assert split_author_string(None) == [] + assert split_author_string(" ") == [] + + +# ── sync / query ────────────────────────────────────────────────────────────── + +def test_sync_book_authors_creates_rows_and_links(session: Session) -> None: + user_id = 1 + book_id = 1 + sync_book_authors(session, user_id, book_id, ["Isaac Asimov", "Frank Herbert"]) + session.commit() + + names = authors_list_for_book(session, book_id) + assert names == ["Frank Herbert", "Isaac Asimov"] # alphabetical + + authors = session.exec(select(Author).where(Author.user_id == user_id)).all() + assert {a.name for a in authors} == {"Isaac Asimov", "Frank Herbert"} + + +def test_sync_book_authors_reuses_existing_author(session: Session) -> None: + user_id = 1 + sync_book_authors(session, user_id, 1, ["Isaac Asimov"]) + session.commit() + first_id = session.exec( + select(Author.id).where(Author.user_id == user_id, Author.name == "Isaac Asimov") + ).one() + + sync_book_authors(session, user_id, 2, ["Isaac Asimov", "Frank Herbert"]) + session.commit() + + second_id = session.exec( + select(Author.id).where(Author.user_id == user_id, Author.name == "Isaac Asimov") + ).one() + assert first_id == second_id + + link_count = session.exec( + select(col(BookAuthor.author_id)).where(BookAuthor.author_id == second_id) + ).all() + assert len(link_count) == 2 + + +def test_sync_book_authors_removes_stale_links(session: Session) -> None: + user_id = 1 + sync_book_authors(session, user_id, 1, ["Isaac Asimov", "Frank Herbert"]) + session.commit() + + sync_book_authors(session, user_id, 1, ["Frank Herbert"]) + session.commit() + + names = authors_list_for_book(session, 1) + assert names == ["Frank Herbert"] + + +def test_sync_book_authors_empty_clears_links(session: Session) -> None: + user_id = 1 + sync_book_authors(session, user_id, 1, ["Isaac Asimov"]) + session.commit() + + sync_book_authors(session, user_id, 1, []) + session.commit() + + assert authors_list_for_book(session, 1) == [] + + +# ── join ────────────────────────────────────────────────────────────────────── + +def test_join_authors() -> None: + assert join_authors(["A", "B"]) == "A, B" + assert join_authors([]) is None + assert join_authors(None) is None \ No newline at end of file diff --git a/backend/tests/test_books.py b/backend/tests/test_books.py index 436599b6..c2eb0c65 100644 --- a/backend/tests/test_books.py +++ b/backend/tests/test_books.py @@ -81,6 +81,105 @@ def test_create_book_invalid_rating_returns_422(client: TestClient) -> None: assert resp.status_code == 422 +# ── authors (multiple) ───────────────────────────────────────────────────────── + +def test_create_book_with_authors_list(client: TestClient) -> None: + resp = client.post("/api/books", json={ + "title": "Good Omens", + "authors": ["Terry Pratchett", "Neil Gaiman"], + "page_count": 288, + }) + assert resp.status_code == 201 + data = resp.json() + assert data["authors"] == ["Neil Gaiman", "Terry Pratchett"] + assert data["author"] == "Neil Gaiman, Terry Pratchett" + + +def test_create_book_legacy_author_comma_separated(client: TestClient) -> None: + resp = client.post("/api/books", json={"title": "Dune", "author": "Frank Herbert", "page_count": 412}) + assert resp.status_code == 201 + data = resp.json() + assert data["authors"] == ["Frank Herbert"] + assert data["author"] == "Frank Herbert" + + +def test_create_book_authors_takes_precedence_over_author(client: TestClient) -> None: + resp = client.post("/api/books", json={ + "title": "Dune", + "author": "Wrong Author", + "authors": ["Frank Herbert"], + "page_count": 412, + }) + assert resp.status_code == 201 + data = resp.json() + assert data["authors"] == ["Frank Herbert"] + + +def test_create_book_no_author_returns_422(client: TestClient) -> None: + resp = client.post("/api/books", json={"title": "No Author", "page_count": 100}) + assert resp.status_code == 422 + detail = resp.json()["detail"] + assert any( + "at least one author" in item.get("msg", "").lower() + for item in detail + if isinstance(item, dict) + ) + + +def test_update_book_authors_replaces(client: TestClient) -> None: + book = _create_book(client, title="Dune", author="Frank Herbert") + resp = client.patch(f"/api/books/{book['id']}", json={"authors": ["Frank Herbert", "Brian Herbert"]}) + assert resp.status_code == 200 + data = resp.json() + assert data["authors"] == ["Brian Herbert", "Frank Herbert"] + assert data["author"] == "Brian Herbert, Frank Herbert" + + +def test_update_book_authors_empty_clears(client: TestClient) -> None: + book = _create_book(client, title="Dune", author="Frank Herbert") + resp = client.patch(f"/api/books/{book['id']}", json={"authors": []}) + assert resp.status_code == 200 + data = resp.json() + assert data["authors"] == [] + assert data["author"] is None + + +def test_update_book_without_authors_keeps_them(client: TestClient) -> None: + book = _create_book(client, title="Dune", author="Frank Herbert") + resp = client.patch(f"/api/books/{book['id']}", json={"title": "Dune 2"}) + assert resp.status_code == 200 + data = resp.json() + assert data["authors"] == ["Frank Herbert"] + + +def test_update_book_legacy_author_parses(client: TestClient) -> None: + book = _create_book(client, title="Dune", author="Frank Herbert") + resp = client.patch(f"/api/books/{book['id']}", json={"author": "Isaac Asimov"}) + assert resp.status_code == 200 + data = resp.json() + assert data["authors"] == ["Isaac Asimov"] + + +def test_list_books_joins_authors(client: TestClient) -> None: + _create_book(client, title="Good Omens", authors=["Terry Pratchett", "Neil Gaiman"]) + resp = client.get("/api/books") + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + book = body["books"][0] + assert book["authors"] == ["Neil Gaiman", "Terry Pratchett"] + assert book["author"] == "Neil Gaiman, Terry Pratchett" + + +def test_get_book_returns_authors_list(client: TestClient) -> None: + book = _create_book(client, title="Good Omens", authors=["Terry Pratchett", "Neil Gaiman"]) + resp = client.get(f"/api/books/{book['id']}") + assert resp.status_code == 200 + data = resp.json() + assert data["authors"] == ["Neil Gaiman", "Terry Pratchett"] + assert data["author"] == "Neil Gaiman, Terry Pratchett" + + # ── list ────────────────────────────────────────────────────────────────────── def test_list_books_empty(client: TestClient) -> None: @@ -142,6 +241,167 @@ def test_list_books_search_by_author(client: TestClient) -> None: assert body["books"][0]["title"] == "Foundation" +def test_list_books_search_by_publisher(client: TestClient) -> None: + _create_book(client, title="Dune", publisher="Ace Books") + _create_book(client, title="Foundation", publisher="Gnome Press") + resp = client.get("/api/books?q=publisher:gnome") + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + assert body["books"][0]["title"] == "Foundation" + + +def test_list_books_search_by_notes(client: TestClient) -> None: + _create_book(client, title="Dune", notes="spice mining") + _create_book(client, title="Foundation", notes="psychohistory") + resp = client.get("/api/books?q=notes:spice") + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + assert body["books"][0]["title"] == "Dune" + + +def test_list_books_search_by_description(client: TestClient) -> None: + _create_book(client, title="Dune", blurb="A desert planet saga.") + _create_book(client, title="Foundation", blurb="A galactic empire collapses.") + resp = client.get("/api/books?q=description:desert") + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + assert body["books"][0]["title"] == "Dune" + + +def test_list_books_search_by_language(client: TestClient) -> None: + _create_book(client, title="Dune", language="de") + _create_book(client, title="Foundation", language="en") + resp = client.get("/api/books?q=language:de") + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + assert body["books"][0]["title"] == "Dune" + + +def test_list_books_search_by_possession(client: TestClient) -> None: + _create_book(client, title="Borrowed", acquisition_status="borrowed") + _create_book(client, title="Owned", acquisition_status="owned") + resp = client.get("/api/books?q=possession:borrowed") + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + assert body["books"][0]["title"] == "Borrowed" + + +def test_list_books_search_by_possession_invalid_value(client: TestClient) -> None: + _create_book(client, title="Borrowed", acquisition_status="borrowed") + resp = client.get("/api/books?q=possession:not-a-status") + assert resp.status_code == 200 + assert resp.json()["total"] == 0 + + +def test_list_books_search_by_tag(client: TestClient) -> None: + _create_book(client, title="Dune", tags="science fiction") + _create_book(client, title="Foundation", tags="classic") + resp = client.get("/api/books?q=tag:fiction") + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + assert body["books"][0]["title"] == "Dune" + + +def test_list_books_search_combined_field_and_unprefixed(client: TestClient) -> None: + _create_book(client, title="Die Fragezeichen", author="Christoph Dittert") + _create_book(client, title="Die Fragezeichen", author="Someone Else") + resp = client.get("/api/books?q=fragezeichen author:Dittert") + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + assert body["books"][0]["title"] == "Die Fragezeichen" + assert body["books"][0]["author"] == "Christoph Dittert" + + +def test_list_books_search_quoted_author(client: TestClient) -> None: + _create_book(client, title="Die Fragezeichen", author="Christoph Dittert") + _create_book(client, title="Die Fragezeichen", author="Christoph Other") + resp = client.get('/api/books?q=author:"Christoph Dittert"') + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + assert body["books"][0]["author"] == "Christoph Dittert" + + +def test_list_books_search_unknown_prefix_falls_back_to_unprefixed(client: TestClient) -> None: + _create_book(client, title="foo:bar special") + resp = client.get("/api/books?q=foo:bar") + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + assert body["books"][0]["title"] == "foo:bar special" + + +def test_list_books_search_negated_quoted_unprefixed(client: TestClient) -> None: + _create_book(client, title="Mercedes Cars", author="A") + _create_book(client, title="Cars Only", author="B") + resp = client.get('/api/books?q="cars" -"mercedes"') + assert resp.status_code == 200 + body = resp.json() + assert [b["title"] for b in body["books"]] == ["Cars Only"] + + +def test_list_books_search_negated_field_term(client: TestClient) -> None: + _create_book(client, title="Car Book", tags="cars") + _create_book(client, title="Audi Book", tags="cars,audi") + _create_book(client, title="Audi Only", tags="audi") + resp = client.get("/api/books?q=tag:cars%20-tag:audi") + assert resp.status_code == 200 + body = resp.json() + assert [b["title"] for b in body["books"]] == ["Car Book"] + + +def test_list_books_search_negated_only(client: TestClient) -> None: + _create_book(client, title="Audi Book", tags="audi") + _create_book(client, title="Plain Book", tags="other") + resp = client.get("/api/books?q=-tag:audi") + assert resp.status_code == 200 + body = resp.json() + assert [b["title"] for b in body["books"]] == ["Plain Book"] + + +def test_list_books_search_lone_dash_returns_all(client: TestClient) -> None: + _create_book(client, title="One") + _create_book(client, title="Two") + resp = client.get("/api/books?q=-") + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 2 + + +def test_list_books_search_percent_is_literal(client: TestClient) -> None: + _create_book(client, title="100% Pure") + _create_book(client, title="100 Miles") + resp = client.get("/api/books?q=title:100%25") + assert resp.status_code == 200 + body = resp.json() + assert [b["title"] for b in body["books"]] == ["100% Pure"] + + +def test_list_books_search_negation_includes_nullable_field_rows(client: TestClient) -> None: + _create_book(client, title="Plain") + _create_book(client, title="Mercedes") + resp = client.get("/api/books?q=-mercedes") + assert resp.status_code == 200 + body = resp.json() + assert [b["title"] for b in body["books"]] == ["Plain"] + + +def test_list_books_search_possession_quoted_multiword(client: TestClient) -> None: + _create_book(client, title="Wanted", acquisition_status="to_acquire") + _create_book(client, title="Owned", acquisition_status="owned") + resp = client.get('/api/books?q=possession:"to acquire"') + assert resp.status_code == 200 + body = resp.json() + assert [b["title"] for b in body["books"]] == ["Wanted"] + + def test_list_books_sort_by_rating(client: TestClient) -> None: _create_book(client, title="Low", rating=2) _create_book(client, title="High", rating=5) @@ -976,6 +1236,32 @@ def test_suggest_authors_deduplication(client: TestClient) -> None: assert resp.json()["suggestions"] == ["Frank Herbert"] +def test_search_author_prefix_matches_any_author(client: TestClient) -> None: + _create_book(client, title="Good Omens", authors=["Terry Pratchett", "Neil Gaiman"]) + _create_book(client, title="Hogfather", author="Terry Pratchett") + resp = client.get("/api/books?q=author:Gaiman") + assert resp.status_code == 200 + body = resp.json() + assert [b["title"] for b in body["books"]] == ["Good Omens"] + + +def test_search_unprefixed_matches_author(client: TestClient) -> None: + _create_book(client, title="Good Omens", authors=["Terry Pratchett", "Neil Gaiman"]) + resp = client.get("/api/books?q=Pratchett") + assert resp.status_code == 200 + body = resp.json() + assert [b["title"] for b in body["books"]] == ["Good Omens"] + + +def test_search_negated_author_excludes(client: TestClient) -> None: + _create_book(client, title="Good Omens", authors=["Terry Pratchett", "Neil Gaiman"]) + _create_book(client, title="Hogfather", author="Terry Pratchett") + resp = client.get("/api/books?q=-author:Gaiman") + assert resp.status_code == 200 + body = resp.json() + assert [b["title"] for b in body["books"]] == ["Hogfather"] + + def test_suggest_publishers_returns_matching(client: TestClient) -> None: _create_book(client, title="A", publisher="Ace Books") _create_book(client, title="B", publisher="Bantam Books") diff --git a/backend/tests/test_data.py b/backend/tests/test_data.py index 209da80c..d04d291d 100644 --- a/backend/tests/test_data.py +++ b/backend/tests/test_data.py @@ -141,6 +141,12 @@ def test_data_export_with_progress_and_tags(client: TestClient) -> None: assert export_resp.status_code == 200 with zipfile.ZipFile(io.BytesIO(export_resp.content), "r") as zf: + books = json.loads(zf.read("books.json")) + assert len(books) == 1 + assert books[0]["author"] == "Frank Herbert" + assert books[0]["authors"] == ["Frank Herbert"] + assert books[0]["tags"] == ["Sci-Fi"] + progress = json.loads(zf.read("progress.json")) assert len(progress) == 1 assert progress[0]["page"] == 100 @@ -152,6 +158,33 @@ def test_data_export_with_progress_and_tags(client: TestClient) -> None: assert tags[0]["book_count"] == 1 +def test_data_export_json_author_string_and_tags_list(client: TestClient) -> None: + create_resp = client.post( + "/api/books", + json={ + "title": "Good Omens", + "authors": ["Terry Pratchett", "Neil Gaiman"], + "tags": "fantasy,humor", + "page_count": 288, + "reading_status": "read", + }, + ) + assert create_resp.status_code == 201 + + export_resp = client.post( + "/api/data/export", + json={"datasets": ["books"], "format": "json"}, + ) + assert export_resp.status_code == 200 + + with zipfile.ZipFile(io.BytesIO(export_resp.content), "r") as zf: + books = json.loads(zf.read("books.json")) + assert len(books) == 1 + assert books[0]["author"] == "Neil Gaiman; Terry Pratchett" + assert books[0]["authors"] == ["Neil Gaiman", "Terry Pratchett"] + assert books[0]["tags"] == ["fantasy", "humor"] + + def test_data_import_parse_and_suggest_mapping(client: TestClient, monkeypatch: MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path / "import_temp")) csv_payload = "Title,Author,My Rating\nDune,Frank Herbert,5\n" @@ -172,7 +205,7 @@ def test_data_import_parse_and_suggest_mapping(client: TestClient, monkeypatch: assert suggest_resp.status_code == 200 suggested = suggest_resp.json()["suggested_mapping"] assert suggested["title"]["source"] == "Title" - assert suggested["author"]["source"] == "Author" + assert suggested["authors"]["source"] == "Author" assert suggested["rating"]["source"] == "My Rating" diff --git a/backend/tests/test_data_import.py b/backend/tests/test_data_import.py index c8529e1a..d9ea2869 100644 --- a/backend/tests/test_data_import.py +++ b/backend/tests/test_data_import.py @@ -1,7 +1,9 @@ """Unit tests for app.services.data_import module.""" +import io import json import os +import zipfile from datetime import datetime, timezone from io import BytesIO from pathlib import Path @@ -11,12 +13,15 @@ import pytest from pytest import MonkeyPatch from sqlalchemy.exc import IntegrityError -from sqlmodel import Session +from sqlmodel import Session, select from app.config import settings -from app.models import Book, ReadingStatus, User, UserRole +from app.models import AcquisitionStatus, Book, ReadingStatus, User, UserRole from app.schemas import ImportFieldConfig from app.services import data_import as di +from app.services.authors import authors_list_for_book, sync_book_authors +from app.services.data_export import _serialize_datetime, build_export_zip +from app.services.tags import sync_book_tags, tags_list_for_book # ── _display_value / _format_value_error ────────────────────────────────────── @@ -50,9 +55,21 @@ def test_to_flat_row_nested_dict_raises() -> None: di._to_flat_row({"key": {"nested": 1}}) -def test_to_flat_row_nested_list_raises() -> None: - with pytest.raises(ValueError, match="error.importNestedValuesNotSupported"): - di._to_flat_row({"key": [1, 2]}) +def test_to_flat_row_list_is_preserved() -> None: + """List values are kept as-is so JSON author arrays survive flattening.""" + flat = di._to_flat_row({"author": ["Asimov, Isaac", "Robert Heinlein"]}) + assert flat["author"] == ["Asimov, Isaac", "Robert Heinlein"] + + +def test_canonicalize_mapping_renames_author_target() -> None: + """Legacy 'author' targets map to the canonical 'authors' field.""" + from app.schemas import ImportFieldConfig + + canonical = di.canonicalize_mapping( + {"title": ImportFieldConfig(source="Title"), "author": ImportFieldConfig(source="Author")} + ) + assert set(canonical) == {"title", "authors"} + assert canonical["authors"].source == "Author" # ── parse_upload ────────────────────────────────────────────────────────────── @@ -73,6 +90,21 @@ def test_parse_upload_csv_missing_header() -> None: di.parse_upload(b"\n", "test.csv", 1) +def test_parse_upload_csv_custom_delimiter(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + csv = "Title;Author\nDune;Frank Herbert\n" + result = di.parse_upload(csv.encode(), "test.csv", 1, delimiter=";") + assert result["format"] == "csv" + assert result["source_fields"] == ["Title", "Author"] + assert result["sample_rows"][0] == {"Title": "Dune", "Author": "Frank Herbert"} + + +def test_parse_upload_csv_invalid_delimiter(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + with pytest.raises(ValueError, match="error.importInvalidDelimiter"): + di.parse_upload(b"Title\nDune\n", "test.csv", 1, delimiter=";;") + + def test_parse_upload_json_not_array() -> None: payload = json.dumps({"key": "value"}).encode() with pytest.raises(ValueError, match="error.importJsonMustBeArray"): @@ -241,6 +273,60 @@ def test_mapped_row_skips_empty_source() -> None: assert result == {"author": ""} # row.get("B") returns None -> "" +def test_mapped_row_array_authors_skips_transform() -> None: + """A list source mapped to `authors` bypasses the transform (scalar-only).""" + result = di._mapped_row( + {"A": ["Neil Gaiman", "Terry Pratchett"]}, + {"authors": ImportFieldConfig(source="A", transform="value.upper()")}, + {}, + {}, + ) + assert result == {"authors": ["Neil Gaiman", "Terry Pratchett"]} + + +def test_mapped_row_transform_returning_list_kept_for_authors() -> None: + """A transform that returns a list (e.g. value.split(';')) stays a list for + the adaptive `authors` target, so the preview renders a JSON array.""" + transform_cache = di._build_transform_cache( + {"authors": ImportFieldConfig(source="A", transform="value.split(';')")} + ) + result = di._mapped_row( + {"A": "Doe, Jane; Mike; mansarde"}, + {"authors": ImportFieldConfig(source="A", transform="value.split(';')")}, + transform_cache, + {}, + ) + assert result == {"authors": ["Doe, Jane", " Mike", " mansarde"]} + + +def test_mapped_row_transform_returning_list_kept_for_tags() -> None: + """Same list pass-through applies to the adaptive `tags` target.""" + transform_cache = di._build_transform_cache( + {"tags": ImportFieldConfig(source="A", transform="value.split(',')")} + ) + result = di._mapped_row( + {"A": "fantasy,humor"}, + {"tags": ImportFieldConfig(source="A", transform="value.split(',')")}, + transform_cache, + {}, + ) + assert result == {"tags": ["fantasy", "humor"]} + + +def test_mapped_row_transform_returning_list_stringified_for_scalar_target() -> None: + """A list result on a non-adaptive target (e.g. title) is stringified.""" + transform_cache = di._build_transform_cache( + {"title": ImportFieldConfig(source="A", transform="value.split(' ')")} + ) + result = di._mapped_row( + {"A": "Dune Messiah"}, + {"title": ImportFieldConfig(source="A", transform="value.split(' ')")}, + transform_cache, + {}, + ) + assert result == {"title": "['Dune', 'Messiah']"} + + # ── _validate_mapping ───────────────────────────────────────────── def test_validate_mapping_empty_mapping() -> None: @@ -289,6 +375,47 @@ def test_parse_acquisition_status_rejects_invalid_value() -> None: # ── preview_import ──────────────────────────────────────────────────────────── +def test_preview_import_json_lists_render_as_arrays( + session: Session, tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + """JSON `authors`/`tags` arrays stay arrays in the preview, and the + canonical `authors` target (not legacy `author`) is shown.""" + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [ + { + "title": "Good Omens", + "author": "Neil Gaiman; Terry Pratchett", + "authors": ["Neil Gaiman", "Terry Pratchett"], + "tags": ["fantasy", "humor"], + "page_count": 288, + "reading_status": "want_to_read", + } + ], + "source_fields": ["title", "author", "authors", "tags", "page_count", "reading_status"], + } + file_id = "test_preview_lists" + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + mapping = di.suggest_mapping(list(payload["source_fields"])) + result = di.preview_import(file_id, user, mapping) + assert len(result["preview_rows"]) == 1 + row = result["preview_rows"][0] + + # Source keeps raw file values: author string stays a string, lists stay lists. + assert row["source"]["author"] == "Neil Gaiman; Terry Pratchett" + assert row["source"]["authors"] == ["Neil Gaiman", "Terry Pratchett"] + assert row["source"]["tags"] == ["fantasy", "humor"] + + # Transformed shows only the canonical `authors` target, as an array. + assert "author" not in row["transformed"] + assert row["transformed"]["authors"] == ["Neil Gaiman", "Terry Pratchett"] + assert row["transformed"]["tags"] == ["fantasy", "humor"] + + def test_preview_import_basic(session: Session, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) user = _create_test_user(session) @@ -351,6 +478,11 @@ def test_preview_import_mapping_errors(session: Session, tmp_path: Path, monkeyp # ── validate_import ─────────────────────────────────────────────────────────── +def _require(value: int | None) -> int: + assert value is not None + return value + + def _create_test_user(session: Session) -> User: """Create and return a test user for import tests.""" from app.auth import get_password_hash @@ -547,6 +679,240 @@ async def test_execute_import_mapping_errors(session: Session, tmp_path: Path, m assert any("Invalid mapping target" in e.get("message", "") for e in events) +@pytest.mark.anyio +async def test_execute_import_tags_list_and_multi_author(session: Session, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + """A JSON backup with list tags and list authors (as exported) round-trips.""" + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [ + { + "title": "Good Omens", + "author": "Neil Gaiman; Terry Pratchett", + "tags": ["fantasy", "humor"], + "page_count": "288", + "reading_status": "want_to_read", + } + ], + "source_fields": ["title", "author", "tags", "page_count", "reading_status"], + } + file_id = "test_exec_tags_list" + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + mapping = { + "title": ImportFieldConfig(source="title"), + "author": ImportFieldConfig(source="author"), + "tags": ImportFieldConfig(source="tags"), + "page_count": ImportFieldConfig(source="page_count"), + "reading_status": ImportFieldConfig(source="reading_status"), + } + events = [] + async for event in di.execute_import(file_id, user, mapping, session, "continue_on_error"): + events.append(event) + complete = [e for e in events if e["event"] == "complete"][0] + assert complete["imported"] == 1 + + book = session.exec(select(Book).where(Book.user_id == user.id)).one() + assert authors_list_for_book(session, book.id) == ["Neil Gaiman", "Terry Pratchett"] + assert tags_list_for_book(session, book.id) == ["fantasy", "humor"] + + +@pytest.mark.anyio +async def test_execute_import_full_library_json_round_trip( + session: Session, tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + """Export a full library as JSON and re-import it, verifying every field. + + Covers a fully-populated book, a multi-author/tagged book, and a minimal + book, including ``date_added``. + """ + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + user_id = _require(user.id) + + # ── Source library ────────────────────────────────────────────────────── + full = Book( + title="Dune", + subtitle="A sci-fi classic", + isbn="9780441013593", + publisher="Ace Books", + published_year=1965, + page_count=412, + language="EN", + notes="Spice must flow.", + blurb="A desert planet, a young duke, and a spice that grants prescience.", + rating=5, + reading_status=ReadingStatus.read, + acquisition_status=AcquisitionStatus.owned, + date_added=datetime(2024, 12, 1, 8, 0, tzinfo=timezone.utc), + date_started=datetime(2025, 1, 10, 9, 30, tzinfo=timezone.utc), + date_finished=datetime(2025, 1, 20, 21, 45, tzinfo=timezone.utc), + user_id=user.id, + ) + multi = Book( + title="Good Omens", + subtitle=None, + isbn="9780060853983", + publisher="William Morrow", + published_year=1990, + page_count=288, + language="EN", + notes=None, + blurb="An angel, a demon, and an approaching apocalypse.", + rating=4, + reading_status=ReadingStatus.want_to_read, + acquisition_status=AcquisitionStatus.digital_access, + date_started=None, + date_finished=None, + user_id=user.id, + ) + minimal = Book( + title="Minimal", + page_count=100, + reading_status=ReadingStatus.want_to_read, + acquisition_status=AcquisitionStatus.owned, + user_id=user.id, + ) + session.add_all([full, multi, minimal]) + session.flush() + full_id = _require(full.id) + multi_id = _require(multi.id) + minimal_id = _require(minimal.id) + + sync_book_authors(session, user_id, full_id, ["Frank Herbert"]) + sync_book_tags(session, user_id, full_id, "sci-fi,classic") + sync_book_authors(session, user_id, multi_id, ["Neil Gaiman", "Terry Pratchett"]) + sync_book_tags(session, user_id, multi_id, ["fantasy", "humor"]) + session.commit() + + # ── Export ─────────────────────────────────────────────────────────────── + zip_bytes, _ = build_export_zip(session, user, ["books"], "json", settings.covers_dir) + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: + exported_rows = json.loads(zf.read("books.json")) + assert len(exported_rows) == 3 + exported_by_title = {row["title"]: row for row in exported_rows} + + # Exported multi-author author string must be the "; "-joined form. + assert exported_by_title["Good Omens"]["author"] == "Neil Gaiman; Terry Pratchett" + assert exported_by_title["Good Omens"]["authors"] == ["Neil Gaiman", "Terry Pratchett"] + assert exported_by_title["Good Omens"]["tags"] == ["fantasy", "humor"] + + # Remove the source library so only the imported copies remain. + for book in (full, multi, minimal): + session.delete(book) + session.commit() + + # ── Re-import the exported rows ────────────────────────────────────────── + file_id = "roundtrip_full" + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"rows": exported_rows, "source_fields": list(exported_rows[0].keys())}) + ) + + mapping = { + field: ImportFieldConfig(source=field) + for field in ( + "title", + "subtitle", + "author", + "isbn", + "publisher", + "published_year", + "page_count", + "language", + "tags", + "notes", + "blurb", + "rating", + "reading_status", + "acquisition_status", + "date_added", + "date_started", + "date_finished", + "cover_url", + ) + } + events = [] + async for event in di.execute_import( + file_id, user, mapping, session, "continue_on_error", require_acquisition_status=True + ): + events.append(event) + complete = [e for e in events if e["event"] == "complete"][0] + assert complete["imported"] == 3 + assert complete["failed"] == 0 + + imported = { + b.title: b for b in session.exec(select(Book).where(Book.user_id == user.id)).all() + } + assert set(imported) == {"Dune", "Good Omens", "Minimal"} + + for title, row in exported_by_title.items(): + book = imported[title] + assert book.subtitle == row["subtitle"] + assert book.isbn == row["isbn"] + assert book.publisher == row["publisher"] + assert book.published_year == row["published_year"] + assert book.page_count == row["page_count"] + assert book.language == row["language"] + assert book.notes == row["notes"] + assert book.blurb == row["blurb"] + assert book.rating == row["rating"] + assert book.reading_status.value == row["reading_status"] + assert book.acquisition_status.value == row["acquisition_status"] + assert book.cover_url == row["cover_url"] + assert _serialize_datetime(book.date_added) == row["date_added"] + assert _serialize_datetime(book.date_started) == row["date_started"] + assert _serialize_datetime(book.date_finished) == row["date_finished"] + assert authors_list_for_book(session, book.id) == row["authors"] + assert tags_list_for_book(session, book.id) == row["tags"] + + +@pytest.mark.anyio +async def test_execute_import_preserves_date_added( + session: Session, tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + """A mapped ``date_added`` is used instead of the import timestamp.""" + monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) + user = _create_test_user(session) + payload = { + "rows": [ + { + "title": "Old Book", + "date_added": "2020-06-01T10:00:00Z", + "page_count": "100", + "reading_status": "want_to_read", + "acquisition_status": "owned", + } + ], + "source_fields": ["title", "date_added", "page_count", "reading_status", "acquisition_status"], + } + file_id = "test_exec_date_added" + path = di._temp_file_path(user.id, file_id) # ty: ignore[invalid-argument-type] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + mapping = { + "title": ImportFieldConfig(source="title"), + "date_added": ImportFieldConfig(source="date_added"), + "page_count": ImportFieldConfig(source="page_count"), + "reading_status": ImportFieldConfig(source="reading_status"), + "acquisition_status": ImportFieldConfig(source="acquisition_status"), + } + events = [] + async for event in di.execute_import( + file_id, user, mapping, session, "continue_on_error", require_acquisition_status=True + ): + events.append(event) + complete = [e for e in events if e["event"] == "complete"][0] + assert complete["imported"] == 1 + + book = session.exec(select(Book).where(Book.user_id == user.id)).one() + assert _serialize_datetime(book.date_added) == "2020-06-01T10:00:00Z" + + @pytest.mark.anyio async def test_execute_import_rating_out_of_range_set_to_none(session: Session, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) diff --git a/backend/tests/test_gamification.py b/backend/tests/test_gamification.py new file mode 100644 index 00000000..0d426199 --- /dev/null +++ b/backend/tests/test_gamification.py @@ -0,0 +1,371 @@ +"""Tests for dashboard gamification — reading streaks and reading goals.""" + +from datetime import date, datetime, timedelta, timezone +from typing import Any + +from sqlmodel import Session, select + +from app.models import Book, ReadingProgress, UserSettings +from app.routers.statistics import current_streak, longest_streak + + +def _create_book(client: Any, **overrides: Any) -> dict[str, Any]: + """Helper to create a book via the API and return the JSON response.""" + payload = {"title": "Book", "author": "Test Author", "page_count": 100, **overrides} + resp = client.post("/api/books", json=payload) + assert resp.status_code == 201 + return resp.json() + + +def _add_progress(session: Session, book: dict[str, Any], page: int, when: datetime) -> None: + db_book = session.get(Book, book["id"]) + assert db_book is not None + assert db_book.id is not None + assert db_book.user_id is not None + session.add( + ReadingProgress( + book_id=db_book.id, user_id=db_book.user_id, page=page, created_at=when + ) + ) + session.commit() + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def _days_ago(days: int) -> datetime: + return _utc_now() - timedelta(days=days) + + +def test_current_streak_today_active() -> None: + today = date(2026, 8, 26) + assert current_streak({"2026-08-26"}, today) == 1 + assert current_streak({"2026-08-26", "2026-08-25", "2026-08-24"}, today) == 3 + assert current_streak({"2026-08-26", "2026-08-24"}, today) == 1 + + +def test_current_streak_does_not_break_when_today_not_logged() -> None: + today = date(2026, 8, 26) + assert current_streak({"2026-08-25"}, today) == 1 + assert current_streak({"2026-08-25", "2026-08-24", "2026-08-23"}, today) == 3 + + +def test_current_streak_breaks_when_yesterday_not_logged() -> None: + today = date(2026, 8, 26) + assert current_streak({"2026-08-24"}, today) == 0 + assert current_streak({"2026-08-26", "2026-08-24"}, today) == 1 + assert current_streak(set(), today) == 0 + + +def test_longest_streak_finds_longest_run_and_most_recent_tie() -> None: + active = { + "2026-01-01", "2026-01-02", "2026-01-03", "2026-01-04", "2026-01-05", + "2026-01-10", "2026-01-11", "2026-01-12", + } + assert longest_streak(active) == (5, "2026-01-01", "2026-01-05") + + tie = {"2026-01-01", "2026-01-02", "2026-03-01", "2026-03-02"} + assert longest_streak(tie) == (2, "2026-03-01", "2026-03-02") + + +def test_longest_streak_single_day_and_empty() -> None: + assert longest_streak({"2026-06-01"}) == (1, "2026-06-01", "2026-06-01") + assert longest_streak(set()) == (0, None, None) + + +# ── API: streaks ─────────────────────────────────────────────────────────────── + + +def test_gamification_empty_library(client: Any) -> None: + resp = client.get("/api/statistics/gamification") + assert resp.status_code == 200 + data = resp.json() + assert data["current_streak"] == 0 + assert data["longest_streak"] == 0 + assert data["longest_streak_start"] is None + assert data["longest_streak_end"] is None + assert data["goals"] == [] + + +def test_gamification_current_streak_today_logged(client: Any, session: Session) -> None: + book = _create_book(client) + _add_progress(session, book, 10, _utc_now()) + + data = client.get("/api/statistics/gamification").json() + assert data["current_streak"] >= 1 + assert data["longest_streak"] >= 1 + + +def test_gamification_current_streak_multiple_consecutive_days(client: Any, session: Session) -> None: + book = _create_book(client) + for day in range(3): + _add_progress(session, book, 10 + day, _days_ago(day)) + + data = client.get("/api/statistics/gamification").json() + assert data["current_streak"] == 3 + + +def test_gamification_streak_does_not_break_without_today(client: Any, session: Session) -> None: + book = _create_book(client) + for day in (1, 2): + _add_progress(session, book, 10 + day, _days_ago(day)) + + data = client.get("/api/statistics/gamification").json() + assert data["current_streak"] == 2 + + +def test_gamification_streak_breaks_after_gap(client: Any, session: Session) -> None: + book = _create_book(client) + _add_progress(session, book, 5, _days_ago(3)) + + data = client.get("/api/statistics/gamification").json() + assert data["current_streak"] == 0 + + +def test_gamification_longest_streak_returns_window(client: Any, session: Session) -> None: + book = _create_book(client) + # two runs: 3 consecutive days ending today, and a longer 4-day run 10+ days ago + for day in range(3): + _add_progress(session, book, 20 + day, _days_ago(day)) + for day in range(10, 14): + _add_progress(session, book, 30 + day, _days_ago(day)) + + data = client.get("/api/statistics/gamification").json() + assert data["longest_streak"] == 4 + assert data["longest_streak_start"] is not None + assert data["longest_streak_end"] is not None + + +def test_gamification_timezone_aware_streak(client: Any, session: Session) -> None: + settings = session.exec(select(UserSettings)).first() + assert settings is not None + settings.timezone = "America/New_York" + session.add(settings) + session.commit() + + book = _create_book(client) + _add_progress(session, book, 10, _utc_now()) + + data = client.get("/api/statistics/gamification").json() + assert data["current_streak"] == 1 + + +# ── API: goals ───────────────────────────────────────────────────────────────── + + +def _set_goal(session: Session, enabled_col: str, target_col: str, enabled: bool, target: int) -> None: + settings = session.exec(select(UserSettings)).first() + assert settings is not None + setattr(settings, enabled_col, enabled) + setattr(settings, target_col, target) + session.add(settings) + session.commit() + + +def test_gamification_goals_disabled_by_default(client: Any, session: Session) -> None: + book = _create_book(client) + _add_progress(session, book, 10, _utc_now()) + + data = client.get("/api/statistics/gamification").json() + assert data["goals"] == [] + + +def test_gamification_goal_pages_per_day(client: Any, session: Session) -> None: + _set_goal(session, "goal_pages_per_day_enabled", "goal_pages_per_day", True, 20) + book = _create_book(client) + _add_progress(session, book, 5, _days_ago(1)) + _add_progress(session, book, 15, _utc_now()) + + goals = client.get("/api/statistics/gamification").json()["goals"] + assert len(goals) == 1 + assert goals[0]["type"] == "pages_per_day" + assert goals[0]["target"] == 20 + assert goals[0]["current"] == 10 + assert goals[0]["reached"] is False + + +def test_gamification_goal_pages_per_day_reached(client: Any, session: Session) -> None: + _set_goal(session, "goal_pages_per_day_enabled", "goal_pages_per_day", True, 20) + book = _create_book(client) + _add_progress(session, book, 5, _days_ago(1)) + _add_progress(session, book, 30, _utc_now()) + + goals = client.get("/api/statistics/gamification").json()["goals"] + assert goals[0]["current"] == 25 + assert goals[0]["reached"] is True + + +def test_gamification_goal_pages_per_month(client: Any, session: Session) -> None: + _set_goal(session, "goal_pages_per_month_enabled", "goal_pages_per_month", True, 300) + book = _create_book(client) + # Two entries on the same day keep the delta fully inside the current month. + now = _utc_now() + _add_progress(session, book, 50, now) + _add_progress(session, book, 200, now) + + goals = client.get("/api/statistics/gamification").json()["goals"] + assert len(goals) == 1 + assert goals[0]["type"] == "pages_per_month" + assert goals[0]["current"] == 150 + assert goals[0]["reached"] is False + + +def test_gamification_goal_pages_per_month_fallback_book(client: Any, session: Session) -> None: + _set_goal(session, "goal_pages_per_month_enabled", "goal_pages_per_month", True, 300) + now = _utc_now() + _create_book( + client, + title="Fallback month pages", + reading_status="read", + page_count=100, + date_started=now.strftime("%Y-%m-%dT%H:%M:%SZ"), + date_finished=now.strftime("%Y-%m-%dT%H:%M:%SZ"), + ) + + goals = client.get("/api/statistics/gamification").json()["goals"] + assert goals[0]["current"] == 100 + + +def test_gamification_goal_books_per_month(client: Any, session: Session) -> None: + _set_goal(session, "goal_books_per_month_enabled", "goal_books_per_month", True, 2) + now = _utc_now() + for i in range(2): + _create_book( + client, + title=f"Finished {i}", + reading_status="read", + date_started=(now - timedelta(days=5)).strftime("%Y-%m-%dT%H:%M:%SZ"), + date_finished=now.strftime("%Y-%m-%dT%H:%M:%SZ"), + ) + + goals = client.get("/api/statistics/gamification").json()["goals"] + assert len(goals) == 1 + assert goals[0]["type"] == "books_per_month" + assert goals[0]["current"] == 2 + assert goals[0]["reached"] is True + + +def test_gamification_goal_books_per_year(client: Any, session: Session) -> None: + _set_goal(session, "goal_books_per_year_enabled", "goal_books_per_year", True, 2) + now = _utc_now() + _create_book( + client, + title="Finished this year", + reading_status="read", + date_started=(now - timedelta(days=5)).strftime("%Y-%m-%dT%H:%M:%SZ"), + date_finished=now.strftime("%Y-%m-%dT%H:%M:%SZ"), + ) + + goals = client.get("/api/statistics/gamification").json()["goals"] + assert len(goals) == 1 + assert goals[0]["type"] == "books_per_year" + assert goals[0]["current"] == 1 + assert goals[0]["reached"] is False + + +def test_gamification_fallback_finished_book_counts_as_active_day(client: Any, session: Session) -> None: + now = _utc_now() + _create_book( + client, + title="No progress, finished today", + reading_status="read", + page_count=150, + date_started=(now - timedelta(days=5)).strftime("%Y-%m-%dT%H:%M:%SZ"), + date_finished=now.strftime("%Y-%m-%dT%H:%M:%SZ"), + ) + # fallback books must have NO progress entries at all + assert session.exec(select(ReadingProgress)).first() is None + + data = client.get("/api/statistics/gamification").json() + assert data["current_streak"] == 1 + assert data["longest_streak"] == 1 + assert data["longest_streak_end"] is not None + + +def test_gamification_fallback_book_page_count_counts_toward_daily_goal( + client: Any, session: Session, +) -> None: + _set_goal(session, "goal_pages_per_day_enabled", "goal_pages_per_day", True, 200) + now = _utc_now() + _create_book( + client, + title="Fallback pages", + reading_status="read", + page_count=150, + date_started=(now - timedelta(days=5)).strftime("%Y-%m-%dT%H:%M:%SZ"), + date_finished=now.strftime("%Y-%m-%dT%H:%M:%SZ"), + ) + + goals = client.get("/api/statistics/gamification").json()["goals"] + assert goals[0]["current"] == 150 + + +# ── API: goal settings validation ───────────────────────────────────────────── + + +def test_settings_goal_target_below_minimum_rejected(client: Any) -> None: + resp = client.patch("/api/profile/settings", json={"goal_pages_per_day": 0}) + assert resp.status_code == 422 + + +def test_settings_goal_target_negative_rejected(client: Any) -> None: + resp = client.patch("/api/profile/settings", json={"goal_books_per_year": -3}) + assert resp.status_code == 422 + + +def test_settings_goal_target_valid_saved(client: Any) -> None: + resp = client.patch( + "/api/profile/settings", + json={"goal_pages_per_day": 25, "goal_pages_per_day_enabled": True}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["goal_pages_per_day"] == 25 + assert data["goal_pages_per_day_enabled"] is True + assert data["goal_books_per_month"] == 2 + assert data["goal_books_per_year"] == 25 + + +def test_gamification_without_settings_row_uses_defaults(client: Any, session: Session) -> None: + for settings in session.exec(select(UserSettings)).all(): + session.delete(settings) + session.commit() + + resp = client.get("/api/statistics/gamification") + assert resp.status_code == 200 + data = resp.json() + assert data["current_streak"] == 0 + assert data["goals"] == [] + + +def test_gamification_enabled_by_default(client: Any, session: Session) -> None: + book = _create_book(client) + _add_progress(session, book, 10, _utc_now()) + + data = client.get("/api/statistics/gamification").json() + assert data["enabled"] is True + + +def test_gamification_disabled_returns_empty(client: Any, session: Session) -> None: + _set_goal(session, "goal_pages_per_day_enabled", "goal_pages_per_day", True, 20) + settings = session.exec(select(UserSettings)).first() + assert settings is not None + settings.gamification_enabled = False + session.add(settings) + session.commit() + + book = _create_book(client) + _add_progress(session, book, 10, _utc_now()) + + data = client.get("/api/statistics/gamification").json() + assert data["enabled"] is False + assert data["current_streak"] == 0 + assert data["longest_streak"] == 0 + assert data["goals"] == [] + + +def test_settings_gamification_enabled_saved(client: Any) -> None: + resp = client.patch("/api/profile/settings", json={"gamification_enabled": False}) + assert resp.status_code == 200 + assert resp.json()["gamification_enabled"] is False \ No newline at end of file diff --git a/backend/tests/test_hygiene.py b/backend/tests/test_hygiene.py index ef065d63..8928b334 100644 --- a/backend/tests/test_hygiene.py +++ b/backend/tests/test_hygiene.py @@ -3,17 +3,22 @@ import pytest from fastapi.testclient import TestClient from pytest import MonkeyPatch -from sqlmodel import Session +from sqlmodel import Session, col, select -from app.models import Book, ReadingStatus, User +from app.models import Author, Book, BookAuthor, ReadingStatus, User from app.routers import hygiene as hygiene_router +from app.services.authors import normalize_author_list def _create_book(session: Session, user_id: int, **overrides: object) -> Book: - """Create a test book with sensible defaults.""" + """Create a test book with sensible defaults. + + The ``author`` override is applied through the author relation table, so an + empty string or None leaves the book without authors. + """ + raw_author: object = overrides.pop("author", "Test Author") defaults: dict = { "title": "Test Book", - "author": "Test Author", "isbn": None, "publisher": "Test Publisher", "published_year": 2020, @@ -30,6 +35,25 @@ def _create_book(session: Session, user_id: int, **overrides: object) -> Book: session.add(book) session.commit() session.refresh(book) + + names: list[str] = ( + [str(raw_author).strip()] + if isinstance(raw_author, str) and str(raw_author).strip() + else [] + ) + if names and book.id is not None: + for name in names: + author = session.exec( + select(Author).where(Author.user_id == user_id, Author.name == name) + ).first() + if author is None: + author = Author(user_id=user_id, name=name) + session.add(author) + session.flush() + assert author.id is not None + session.add(BookAuthor(book_id=book.id, author_id=author.id)) + session.commit() + session.refresh(book) return book @@ -154,8 +178,22 @@ def test_batch_update_single_field(self, client: TestClient, session: Session) - session.refresh(b1) session.refresh(b2) - assert b1.author == "New Author" - assert b2.author == "New Author" + author_names = list( + session.exec( + select(Author.name) + .join(BookAuthor, col(BookAuthor.author_id) == col(Author.id)) + .where(BookAuthor.book_id == b1.id) + ).all() + ) + assert author_names == ["New Author"] + author_names = list( + session.exec( + select(Author.name) + .join(BookAuthor, col(BookAuthor.author_id) == col(Author.id)) + .where(BookAuthor.book_id == b2.id) + ).all() + ) + assert author_names == ["New Author"] def test_batch_update_too_many_ids(self, client: TestClient, session: Session) -> None: """Rejects more than 500 book IDs.""" @@ -393,7 +431,7 @@ def test_batch_update_database_error(self, client: TestClient, session: Session, """A database error during the update should return 500.""" from sqlalchemy.sql.dml import Update - b1 = _create_book(session, 1, title="B1", author="Old") + b1 = _create_book(session, 1, title="B1", published_year=2020) original_exec = session.exec def fake_exec(statement, *args, **kwargs): @@ -405,8 +443,8 @@ def fake_exec(statement, *args, **kwargs): resp = client.post("/api/hygiene/batch-update", json={ "book_ids": [b1.id], - "field": "author", - "value": "New", + "field": "published_year", + "value": 2021, }) assert resp.status_code == 500 assert resp.json()["detail"] == "Batch update failed due to a database error" diff --git a/backend/tests/test_search_query.py b/backend/tests/test_search_query.py new file mode 100644 index 00000000..a288f4c3 --- /dev/null +++ b/backend/tests/test_search_query.py @@ -0,0 +1,115 @@ +"""Unit tests for the field-specific search query parser.""" + +from app.services.search import parse_search_query + + +def _terms(query: str) -> list[tuple[str | None, str, bool]]: + return [(t.field, t.value, t.negated) for t in parse_search_query(query)] + + +def test_parse_unprefixed_only() -> None: + assert _terms("dune") == [(None, "dune", False)] + + +def test_parse_single_field() -> None: + assert _terms("author:Marlen") == [("author", "Marlen", False)] + + +def test_parse_quoted_multiword() -> None: + assert _terms('author:"Marlen Haushofer"') == [("author", "Marlen Haushofer", False)] + + +def test_parse_multiple_fields() -> None: + assert _terms("author:Dittert title:fragezeichen") == [ + ("author", "Dittert", False), + ("title", "fragezeichen", False), + ] + + +def test_parse_mixed_prefixed_and_unprefixed() -> None: + assert _terms("fragezeichen author:Dittert") == [ + (None, "fragezeichen", False), + ("author", "Dittert", False), + ] + + +def test_parse_unknown_prefix_kept_in_unprefixed() -> None: + assert _terms("foo:bar") == [(None, "foo:bar", False)] + + +def test_parse_unclosed_quote() -> None: + assert _terms('author:"Marlen') == [("author", "Marlen", False)] + + +def test_parse_empty_quoted_value() -> None: + assert _terms('author:""') == [] + + +def test_parse_case_insensitive_prefix() -> None: + assert _terms("Author:Marlen") == [("author", "Marlen", False)] + assert _terms("TITLE:dune") == [("title", "dune", False)] + + +def test_parse_escaped_quote() -> None: + assert _terms(r'author:"O\"Brian"') == [("author", 'O"Brian', False)] + + +def test_parse_negated_field_term() -> None: + assert _terms("-tag:audi") == [("tag", "audi", True)] + + +def test_parse_negated_quoted_unprefixed() -> None: + assert _terms('"cars" -"mercedes benz"') == [ + (None, "cars", False), + (None, "mercedes benz", True), + ] + + +def test_parse_negated_single_unprefixed() -> None: + assert _terms("-cars") == [(None, "cars", True)] + + +def test_parse_mixed_positive_and_negated() -> None: + assert _terms("tag:cars -tag:audi") == [ + ("tag", "cars", False), + ("tag", "audi", True), + ] + + +def test_parse_lone_negation() -> None: + assert _terms("-") == [] + + +def test_parse_bare_prefix() -> None: + # A bare prefix without a value is treated as literal unprefixed text. + assert _terms("author:") == [(None, "author:", False)] + + +def test_parse_all_supported_prefixes() -> None: + query = "author:a title:t publisher:p tag:g language:en possession:owned notes:n description:d" + fields = [t.field for t in parse_search_query(query)] + assert fields == [ + "author", + "title", + "publisher", + "tag", + "language", + "possession", + "notes", + "description", + ] + + +def test_possession_condition_accepts_enum_values() -> None: + from app.services.search import _possession_condition + + assert _possession_condition("owned") is not None + assert _possession_condition("digital_access") is not None + assert _possession_condition("to acquire") is not None + assert _possession_condition("owned") is not None + + +def test_possession_condition_rejects_unknown_value() -> None: + from app.services.search import _possession_condition + + assert _possession_condition("not-a-status") is None \ No newline at end of file diff --git a/backend/tests/test_statistics.py b/backend/tests/test_statistics.py index 9da70e36..f44a9769 100644 --- a/backend/tests/test_statistics.py +++ b/backend/tests/test_statistics.py @@ -31,6 +31,8 @@ def test_statistics_empty_library(client: Any) -> None: resp = client.get("/api/statistics") assert resp.status_code == 200 data = resp.json() + assert data["total_books"] == 0 + assert data["total_authors"] == 0 assert data["avg_books_per_month"] is None assert data["busiest_month"] is None assert data["avg_page_count"] is None @@ -83,6 +85,8 @@ def test_statistics_core_metrics_and_distributions(client: Any) -> None: assert resp.status_code == 200 data = resp.json() assert data["avg_books_per_month"] == 1.5 + assert data["total_books"] == 5 + assert data["total_authors"] == 2 assert data["busiest_month"] == "2026-01" assert data["busiest_month_count"] == 2 assert data["avg_page_count"] == 164.0 @@ -660,5 +664,5 @@ def test_statistics_top_and_worst_rated_books(client: Any) -> None: data = resp.json() assert data["books_with_rating"] == 4 assert data["average_rating"] == 3.5 - assert [b["title"] for b in data["top_rated_books"]] == ["Bad", "Okay", "Good", "Best"] - assert [b["title"] for b in data["worst_rated_books"]] == ["Best", "Good", "Okay", "Bad"] + assert [b["title"] for b in data["top_rated_books"]] == ["Best", "Good", "Okay", "Bad"] + assert [b["title"] for b in data["worst_rated_books"]] == ["Bad", "Okay", "Good", "Best"] diff --git a/backend/tests/test_transform_engine.py b/backend/tests/test_transform_engine.py index a83280fc..6b9b316f 100644 --- a/backend/tests/test_transform_engine.py +++ b/backend/tests/test_transform_engine.py @@ -135,10 +135,12 @@ def test_bool_return_becomes_string(self) -> None: result = te.execute_transform(fn, "x", {}, {}) assert result == "True" - def test_list_return_becomes_string(self) -> None: + def test_list_return_is_preserved(self) -> None: + """Non-string results (e.g. lists for the adaptive authors/tags targets) + are passed through untouched; scalar fields stringify in the caller.""" fn = te.compile_transform("return [1, 2, 3]") result = te.execute_transform(fn, "x", {}, {}) - assert result == "[1, 2, 3]" + assert result == [1, 2, 3] def test_no_return_statement(self) -> None: fn = te.compile_transform("x = value.strip()") diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 3c2f0221..8b4d093d 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,7 +1,8 @@ services: backend: build: - context: ./backend + context: . + dockerfile: ./backend/Dockerfile args: APP_VERSION: ${APP_VERSION:-v0.0.0-dev} GIT_SHA: ${GIT_SHA:-unknown} diff --git a/docs/.vitepress/config.base.ts b/docs/.vitepress/config.base.ts index a0d94eac..c4aaa661 100644 --- a/docs/.vitepress/config.base.ts +++ b/docs/.vitepress/config.base.ts @@ -10,6 +10,12 @@ export default defineConfig({ __GIT_SHA__: JSON.stringify(gitSha), __GIT_SHA_SHORT__: JSON.stringify(gitSha.slice(0, 7)), }, + optimizeDeps: { + // fastdom is a CJS/UMD dependency of mermaid; without pre-bundling, + // Vite's dev server fails to expose its `default` export ("does not + // provide an export named 'default'"), which renders the dev site blank. + include: ['fastdom', 'fastdom/extensions/fastdom-promised.js'], + }, server: { host: true, port: 5174, @@ -34,6 +40,7 @@ export default defineConfig({ nav: [ { text: 'Guide', link: '/guide/getting-started' }, { text: 'API', link: '/api/' }, + { text: 'Releases', link: '/releases' }, { text: 'About', link: '/about' }, ], sidebar: { @@ -54,6 +61,7 @@ export default defineConfig({ ], }, { text: 'Integrations 🔗', link: '/api/integrations/' }, + { text: 'Release Notes', link: '/releases' }, ], }, { @@ -61,6 +69,7 @@ export default defineConfig({ items: [ { text: 'Dashboard', link: '/guide/using-librislog/dashboard' }, { text: 'Library', link: '/guide/using-librislog/library' }, + { text: 'Search', link: '/guide/using-librislog/search' }, { text: 'Profile', link: '/guide/using-librislog/profile' }, { text: 'Progress Tracking', link: '/guide/using-librislog/progress' }, { text: 'Statistics', link: '/guide/using-librislog/statistics' }, diff --git a/docs/about.md b/docs/about.md index 0b213aa8..ada16ccf 100644 --- a/docs/about.md +++ b/docs/about.md @@ -5,6 +5,7 @@ LibrisLog is a **multi-user book tracking web application** designed for readers ## Features - **Library Management**: Organize books into four reading statuses — Want to Read, Currently Reading, Read, and Did Not Finish +- **Advanced Search**: Find books by title, author, tags, publisher, language, availability, notes, and description using field prefixes and negation - **Book Import**: Search Open Library, Google Books, and Hardcover.app. Scan ISBN barcodes for quick lookup - **Reading Progress**: Track pages read over time with a visual timeline and calendar heatmap - **Statistics Dashboard**: Charts showing pages read per month, books finished, language distribution, and more diff --git a/docs/api/index.md b/docs/api/index.md index 53b8bee5..a972eb6a 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -46,8 +46,24 @@ curl -H "X-API-Key: YOUR_KEY_HERE" \ curl -X POST \ -H "X-API-Key: YOUR_KEY_HERE" \ -H "Content-Type: application/json" \ - -d '{"title": "The Great Gatsby", "author": "F. Scott Fitzgerald"}' \ + -d '{"title": "The Great Gatsby", "authors": ["F. Scott Fitzgerald"]}' \ http://localhost:8000/api/books +``` + +### Book author fields + +Book responses contain two author fields: + +- `author` — **deprecated**. The **joined** string of all authors (e.g. `"Neil Gaiman, Terry Pratchett"`). Kept for backward compatibility with existing consumers; use `authors` instead. +- `authors` — the **list** of individual author names (e.g. `["Neil Gaiman", "Terry Pratchett"]`). + +The `author` field is marked as **deprecated** in the OpenAPI spec (visible in Swagger UI) on all book schemas. It still works but may be removed in a future release. + +When creating a book you must provide at least one author — either `authors` as a list, or the legacy `author` string. If both are sent, `authors` takes precedence. A request with neither (or with an empty `authors` list) is rejected with a `422` validation error. + +For updates, `author`/`authors` are optional; if you send an empty `authors` list the book's authors are cleared. + +The legacy `author` string is **parsed on commas, tag-style** (e.g. `"Isaac Asimov, Frank Herbert"` becomes two authors). This only applies to the API create/update path. It differs from **file import** (CSV/JSON), where a single author string is split on `;`, ` & `, or ` and ` — never on commas — so a name like `"Asimov, Isaac"` stays one author. See [Import & Export](../guide/using-librislog/import-export.md) for the import behaviour. # Update reading status curl -X POST \ diff --git a/docs/guide/database-layout.md b/docs/guide/database-layout.md index db8e8e5b..8d96c83f 100644 --- a/docs/guide/database-layout.md +++ b/docs/guide/database-layout.md @@ -1,3 +1,372 @@ # Database Layout -> _Auto-generated from SQLModel metadata — run `npm run docs:gen-db` (or `uv run --directory backend python scripts/gen_db_docs.py`) to regenerate._ +> _Auto-generated from SQLModel metadata on 2026-08-24._ + +This page documents the LibrisLog database schema. It is intended for +developers who need to understand the data model, write queries, or extend +the application. + +```mermaid +erDiagram + + user ||--|{ apikey : "1:N" + user ||--|{ author : "1:N" + user ||--o{ book : "0..N" + user ||--|{ embed_token : "1:N" + user ||--|{ import_mapping : "1:N" + user ||--|| oidclink : "1:1" + user ||--|{ tag : "1:N" + user ||--|| usersettings : "1:1" + book ||--|{ book_author : "1:N" + author ||--|{ book_author : "1:N" + book ||--|{ book_tag : "1:N" + tag ||--|{ book_tag : "1:N" + book ||--|{ reading_progress : "1:N" + user ||--|{ reading_progress : "1:N" + + user { + integer id PK + varchar firstname + varchar lastname + varchar email UK + varchar role + varchar hashed_password + integer credentials_version + datetime created_at + datetime updated_at + } + + apikey { + integer id PK + integer user_id + varchar key_prefix + varchar key_hash UK + varchar key_encrypted + varchar description + datetime created_at + datetime last_used_at + datetime revoked_at + } + + author { + integer id PK + integer user_id + varchar name + datetime created_at + } + + book { + integer id PK + varchar title + varchar subtitle + varchar isbn + varchar cover_url + varchar publisher + integer published_year + integer page_count + varchar(2) language + varchar notes + varchar blurb + integer rating + varchar reading_status + varchar acquisition_status + integer user_id + datetime date_added + datetime date_started + datetime date_finished + } + + embed_token { + integer id PK + integer user_id + varchar(255) name + varchar token_prefix + varchar token_hash UK + varchar scopes + varchar allowed_origins + datetime expires_at + datetime last_used_at + datetime created_at + datetime revoked_at + } + + import_mapping { + integer id PK + integer user_id + varchar(255) name + varchar(64) schema_fingerprint + varchar source_fields_json + varchar mapping_json + datetime created_at + datetime updated_at + } + + oidclink { + integer id PK + integer user_id UK + varchar provider_id + varchar oidc_sub UK + varchar oidc_email + varchar oidc_name + datetime linked_at + } + + tag { + integer id PK + integer user_id + varchar name + datetime created_at + } + + usersettings { + integer id PK + integer user_id UK + varchar(10) language + varchar(64) timezone + varchar(20) theme + varchar(30) custom_theme + } + + book_author { + integer book_id PK + integer author_id PK + } + + book_tag { + integer book_id PK + integer tag_id PK + } + + reading_progress { + integer id PK + integer book_id + integer user_id + integer page + datetime created_at + datetime updated_at + } + +``` + +## Tables + +### `user` + +A user account. + +| Column | Type | Constraints | Notes | +|--------|------|-------------|-------| +| `id` | `INTEGER` | PK | Auto-increment | +| `firstname` | `VARCHAR` | NOT NULL | | +| `lastname` | `VARCHAR` | NOT NULL | | +| `email` | `VARCHAR` | UNIQUE, NOT NULL | | +| `role` | `VARCHAR` | NOT NULL, INDEX | default `user` | +| `hashed_password` | `VARCHAR` | NOT NULL | | +| `credentials_version` | `INTEGER` | NOT NULL | default 0 | +| `created_at` | `DATETIME` | | UTC | +| `updated_at` | `DATETIME` | | UTC | + +### `apikey` + +API key for programmatic access. + +| Column | Type | Constraints | Notes | +|--------|------|-------------|-------| +| `id` | `INTEGER` | PK | Auto-increment | +| `user_id` | `INTEGER` | FK → user.id, NOT NULL, INDEX | | +| `key_prefix` | `VARCHAR` | NOT NULL, INDEX | | +| `key_hash` | `VARCHAR` | UNIQUE, NOT NULL | | +| `key_encrypted` | `VARCHAR` | | | +| `description` | `VARCHAR` | | | +| `created_at` | `DATETIME` | | UTC | +| `last_used_at` | `DATETIME` | | UTC | +| `revoked_at` | `DATETIME` | | UTC | + +### `author` + +A user-specific author name that can be associated with books. + +| Column | Type | Constraints | Notes | +|--------|------|-------------|-------| +| `id` | `INTEGER` | PK | Auto-increment | +| `user_id` | `INTEGER` | FK → user.id, NOT NULL, INDEX | | +| `name` | `VARCHAR` | NOT NULL, INDEX | | +| `created_at` | `DATETIME` | | UTC | + +**Unique constraint:** `(user_id, name)` — uq_author_user_id_name + +### `book` + +A book in the user's library. + +| Column | Type | Constraints | Notes | +|--------|------|-------------|-------| +| `id` | `INTEGER` | PK | Auto-increment | +| `title` | `VARCHAR` | NOT NULL, INDEX | | +| `subtitle` | `VARCHAR` | | | +| `isbn` | `VARCHAR` | | | +| `cover_url` | `VARCHAR` | | | +| `publisher` | `VARCHAR` | | | +| `published_year` | `INTEGER` | | | +| `page_count` | `INTEGER` | NOT NULL | default 0 | +| `language` | `VARCHAR(2)` | | | +| `notes` | `VARCHAR` | | | +| `blurb` | `VARCHAR` | | | +| `rating` | `INTEGER` | | ≥ 1; ≤ 5 | +| `reading_status` | `VARCHAR` | NOT NULL, INDEX | default `want_to_read` | +| `acquisition_status` | `VARCHAR` | NOT NULL, INDEX | default `owned` | +| `user_id` | `INTEGER` | FK → user.id, INDEX | | +| `date_added` | `DATETIME` | INDEX | UTC | +| `date_started` | `DATETIME` | INDEX | UTC | +| `date_finished` | `DATETIME` | INDEX | UTC | + +**Unique constraint:** `(user_id, isbn)` — uq_book_user_id_isbn + +### `embed_token` + +A scoped embed token for iframe/dashboard integrations. + +| Column | Type | Constraints | Notes | +|--------|------|-------------|-------| +| `id` | `INTEGER` | PK | Auto-increment | +| `user_id` | `INTEGER` | FK → user.id, NOT NULL, INDEX | | +| `name` | `VARCHAR(255)` | NOT NULL | | +| `token_prefix` | `VARCHAR` | NOT NULL, INDEX | | +| `token_hash` | `VARCHAR` | UNIQUE, NOT NULL | | +| `scopes` | `VARCHAR` | NOT NULL | default `embed:stats:read` | +| `allowed_origins` | `VARCHAR` | | | +| `expires_at` | `DATETIME` | | UTC | +| `last_used_at` | `DATETIME` | | UTC | +| `created_at` | `DATETIME` | | UTC | +| `revoked_at` | `DATETIME` | | UTC | + +### `import_mapping` + +A saved column-mapping configuration for data import. + +| Column | Type | Constraints | Notes | +|--------|------|-------------|-------| +| `id` | `INTEGER` | PK | Auto-increment | +| `user_id` | `INTEGER` | FK → user.id, NOT NULL, INDEX | | +| `name` | `VARCHAR(255)` | NOT NULL | | +| `schema_fingerprint` | `VARCHAR(64)` | NOT NULL, INDEX | | +| `source_fields_json` | `VARCHAR` | NOT NULL | | +| `mapping_json` | `VARCHAR` | NOT NULL | | +| `created_at` | `DATETIME` | | UTC | +| `updated_at` | `DATETIME` | | UTC | + +**Unique constraint:** `(user_id, name)` — uq_import_mapping_user_id_name + +### `oidclink` + +Links an OIDC identity to a local user account. + +| Column | Type | Constraints | Notes | +|--------|------|-------------|-------| +| `id` | `INTEGER` | PK | Auto-increment | +| `user_id` | `INTEGER` | FK → user.id, UNIQUE, NOT NULL | | +| `provider_id` | `VARCHAR` | NOT NULL, INDEX | | +| `oidc_sub` | `VARCHAR` | UNIQUE, NOT NULL | | +| `oidc_email` | `VARCHAR` | | | +| `oidc_name` | `VARCHAR` | | | +| `linked_at` | `DATETIME` | | UTC | + +### `tag` + +A user-specific tag that can be applied to books. + +| Column | Type | Constraints | Notes | +|--------|------|-------------|-------| +| `id` | `INTEGER` | PK | Auto-increment | +| `user_id` | `INTEGER` | FK → user.id, NOT NULL, INDEX | | +| `name` | `VARCHAR` | NOT NULL, INDEX | | +| `created_at` | `DATETIME` | | UTC | + +**Unique constraint:** `(user_id, name)` — uq_tag_user_id_name + +### `usersettings` + +Per-user settings such as language, timezone, and theme. + +| Column | Type | Constraints | Notes | +|--------|------|-------------|-------| +| `id` | `INTEGER` | PK | Auto-increment | +| `user_id` | `INTEGER` | FK → user.id, UNIQUE, NOT NULL | | +| `language` | `VARCHAR(10)` | NOT NULL | default `en` | +| `timezone` | `VARCHAR(64)` | NOT NULL | default `UTC` | +| `theme` | `VARCHAR(20)` | NOT NULL | default `light` | +| `custom_theme` | `VARCHAR(30)` | | | + +### `book_author` + +Many-to-many association between books and authors. + +| Column | Type | Constraints | Notes | +|--------|------|-------------|-------| +| `book_id` | `INTEGER` | PK, FK → book.id | | +| `author_id` | `INTEGER` | PK, FK → author.id | | + +### `book_tag` + +Many-to-many association between books and tags. + +| Column | Type | Constraints | Notes | +|--------|------|-------------|-------| +| `book_id` | `INTEGER` | PK, FK → book.id | | +| `tag_id` | `INTEGER` | PK, FK → tag.id | | + +### `reading_progress` + +A page-number reading progress entry for a book. + +| Column | Type | Constraints | Notes | +|--------|------|-------------|-------| +| `id` | `INTEGER` | PK | Auto-increment | +| `book_id` | `INTEGER` | FK → book.id, NOT NULL, INDEX | | +| `user_id` | `INTEGER` | FK → user.id, NOT NULL, INDEX | | +| `page` | `INTEGER` | NOT NULL | ≥ 0 | +| `created_at` | `DATETIME` | | UTC | +| `updated_at` | `DATETIME` | | UTC | + + +## Enums + +### `AcquisitionStatus` + +| Value | Meaning | +|-------|---------| +| `owned` | Owned | +| `borrowed` | Borrowed | +| `digital_access` | Digital Access | +| `to_acquire` | To Acquire | + +### `ReadingStatus` + +| Value | Meaning | +|-------|---------| +| `want_to_read` | Want To Read | +| `currently_reading` | Currently Reading | +| `read` | Read | +| `did_not_finish` | Did Not Finish | + +### `UserRole` + +| Value | Meaning | +|-------|---------| +| `admin` | Admin | +| `user` | User | + + +## Conventions + +- **Timestamps** are stored as UTC via the `UtcDateTime` type decorator. + Values are stored as naive UTC in SQLite and returned as timezone-aware + `datetime` objects by the application. +- **Soft deletes** — `ApiKey` and `EmbedToken` use a `revoked_at` timestamp + instead of `DELETE`. Revoked entries are excluded from all queries. +- **Foreign keys** — all user-owned tables reference `user.id` via foreign + key constraints. Cascading behavior is handled in application code (not + at the database level). +- **Unique constraints** — compound constraints like `(user_id, isbn)` on + `book` and `(user_id, name)` on `tag` enforce per-user uniqueness without + restricting other users. diff --git a/docs/guide/using-librislog/dashboard.md b/docs/guide/using-librislog/dashboard.md index 2be01dd0..eec49c29 100644 --- a/docs/guide/using-librislog/dashboard.md +++ b/docs/guide/using-librislog/dashboard.md @@ -6,7 +6,9 @@ The dashboard is the first page you see after logging in. It gives you an overvi ## Search -The search bar at the top of the dashboard lets you find books by title, author, or tags. The result count updates as you type and matching books appear in a dropdown below the bar. +The search bar at the top of the dashboard lets you find books by title, author, or tags. It also supports field-specific queries such as `author:Murakami` and negation such as `Haushofer -"Die Wand"`. See the [search syntax reference](/guide/using-librislog/search) for the full list of supported prefixes and examples. + +The result count updates as you type and matching books appear in a dropdown below the bar. - **Arrow keys** to navigate the dropdown - **Enter** opens the selected book's detail view; if no item is selected, it navigates to the dedicated search results page (`/search`) showing all matches @@ -27,6 +29,19 @@ Books from your "Want to Read" list are shown as suggestions — pick one to sta A random quote is displayed at the top of the dashboard (configurable via `DASHBOARD_QUOTE_ENABLED` in `.env`). +## Reading Streaks & Goals + +A gamification section on the dashboard keeps you motivated: + +- **Current Streak** — the number of consecutive days with reading activity, counted backwards from today. Today counts as the first day when you have logged progress; a not-yet-logged today does **not** break your streak. +- **Longest Streak** — your all-time longest run, with the start and end dates shown as a subtitle. + +A day counts as a reading day when you logged at least one reading-progress entry on it, or when you finished a book that has no progress entries. Streaks are computed on the fly in your configured [timezone](/guide/using-librislog/profile#timezone). + +When you enable a reading goal on your [profile page](/guide/using-librislog/profile#reading-goals), the section also shows playful progress cards for every active goal — a progress bar, the current value vs. the target, and a **"Goal reached"** badge once you hit it. The card updates immediately when you save reading progress or delete a log entry from the book detail view. + +The whole section can be hidden from the profile page under **Reading Goals → "Show reading streaks & goals on dashboard"**. + ## Tag Cloud The most common tags in your library are shown, sized by frequency. Click any tag to filter your library by it. diff --git a/docs/guide/using-librislog/import-export.md b/docs/guide/using-librislog/import-export.md index dd5fbe75..8b21b094 100644 --- a/docs/guide/using-librislog/import-export.md +++ b/docs/guide/using-librislog/import-export.md @@ -29,6 +29,12 @@ On mobile devices: If no search results are found, enter book details manually. Title, author, page count, and availability are required; all other fields are optional. +Authors can be added as multiple values: type a name and press **Enter** (or pick a suggestion) to add a chip. A book can have any number of authors. Commas inside an author name (e.g. `Asimov, Isaac`) are preserved — they are not treated as separators. + +### Search Import + +When a search source returns multiple authors for a book (e.g. Open Library, Google Books, or Hardcover), the app keeps them as a list and creates one author per entry. Sources that return a single combined string are split only on `;`, ` & `, or ` and ` — never on commas. + ## Data Export Export your entire library or subsets of data: @@ -67,6 +73,10 @@ Import data from external sources: - **JSON** — LibrisLog export format - **CSV** — Custom field mapping supported +The JSON export mirrors the API shape: `author` is the joined string (separated with `; `), `authors` is the list of names, and `tags` is a list of tag names. All three round-trip through the adaptive import. + +For CSV files, a **delimiter** field appears once a `.csv` file is selected (default `,`). Enter the character your file uses to separate columns (e.g. `;` for German/Excel exports) before clicking **Parse file**. + ### Field Mapping When importing CSV, map source columns to LibrisLog fields: @@ -76,6 +86,19 @@ When importing CSV, map source columns to LibrisLog fields: `acquisition_status` is required for imports. Map it to one of `owned`, `borrowed`, `digital_access`, or `to_acquire`; use a transform when the source file uses different names. +`date_added` is importable too — useful when migrating from another tool so the original "added to library" dates are preserved (the LibrisLog JSON export includes it, so exports round-trip losslessly). If a row has no `date_added`, the import timestamp is used. + +#### Authors are adaptive + +The import target field is **`authors`**. Its source value adapts: + +- **Array value** (e.g. a JSON `authors` list) → each array entry becomes a separate author. +- **String value** (e.g. a CSV cell) → normally becomes **one** author, and commas inside the name are preserved, so `"Asimov, Isaac"` stays a single author. To encode several authors in a single cell, separate them with `;`, ` & `, or ` and ` (e.g. `"Frank Herbert; Brian Herbert"`). This is how the CSV export writes the dedicated `authors` column, so exports round-trip losslessly. + +The import preview shows how each row's author value will be interpreted before you import. + +The `tags` field is adaptive too: a JSON `tags` array contributes one tag per entry, while a comma-separated string (CSV) is split on commas. + ### Transform DSL Per-field Python expressions allow data transformation: diff --git a/docs/guide/using-librislog/library.md b/docs/guide/using-librislog/library.md index f8a0de29..a02c2c0a 100644 --- a/docs/guide/using-librislog/library.md +++ b/docs/guide/using-librislog/library.md @@ -13,11 +13,11 @@ Books are categorized into four statuses: | **Read** | Books you've finished | | **Did Not Finish** | Books you started but abandoned | -Each status has its own tab in the library view, making it easy to browse your collection by reading state. +Each status has its own tab in the library view, making it easy to browse your collection by reading state. A fifth **All Books** tab shows every book regardless of status; like the other tabs it supports search and sorting (smart sort is disabled there, since it's based on per-status defaults). -## Availability +## Possession -Availability is separate from reading status. Choose whether a book is owned, borrowed, available digitally, or still needs to be acquired. In the Want to Read view, books that still need to be acquired show a shopping-cart indicator. Use the availability filter to narrow the list without changing its newest-first order. +Possession is separate from reading status. Choose whether a book is owned, borrowed, available digitally, or still needs to be acquired. In the Want to Read view, books that still need to be acquired show a shopping-cart indicator. Use the possession filter to narrow the list without changing its newest-first order. ![Library](/screenshots/library-read.png) @@ -54,6 +54,8 @@ Clicking a book opens the detail dialog/drawer showing: Use the "Add Book" button to manually enter book details. Fill in title, author, and optional fields like ISBN, publisher, page count, etc. +A book can have **multiple authors**: type a name and press **Enter** to add it as a chip. Authors are shown joined with "; " throughout the app, so names written last-name-first (e.g. `"Doe, Jane"`) stay unambiguous. + ### Import Search Search external sources for book metadata: @@ -95,6 +97,7 @@ Downloaded covers are cached locally in the `COVERS_DIR` directory to avoid repe ## Search - Search books by title, author, or tags using the search bar — the result count updates as you type +- Field-specific queries are supported, e.g. `author:Murakami`, `availability:owned`, or `tag:fantasy`. See the [search syntax reference](/guide/using-librislog/search) for the full list of prefixes and examples. - Press **Enter** to open the dedicated search results page with a full results grid, load-more pagination, and the same book detail interaction as the library - From any page, navigate directly to `/search?q=your+query` for quick access diff --git a/docs/guide/using-librislog/profile.md b/docs/guide/using-librislog/profile.md index 1655bfd8..44411787 100644 --- a/docs/guide/using-librislog/profile.md +++ b/docs/guide/using-librislog/profile.md @@ -26,6 +26,23 @@ Set your preferred timezone for date/time displays (e.g., for the calendar heatm Choose a custom DaisyUI theme from the dropdown. The theme previews in real-time as you browse the dropdown, and the selection is saved to your profile so it persists across sessions. +## Reading Goals + +Set personal reading targets that are tracked on the dashboard's **Reading Streaks & Goals** section: + +| Goal | Default target | +|---|---| +| Pages per Day | 20 | +| Pages per Month | 300 | +| Books per Month | 2 | +| Books per Year | 25 | + +Every goal is **disabled by default**. Toggle a goal on and set its target (at least 1) — enabled goals then appear as progress cards on the dashboard. Saving shows a confirmation notification like the other settings sections. + +The **"Show reading streaks & goals on dashboard"** switch above the goals disables or re-enables the entire streaks & goals section on the dashboard. + +See [Dashboard → Reading Streaks & Goals](/guide/using-librislog/dashboard#reading-streaks-goals) for details on how streaks and goal progress are calculated. + ## API Keys Create and manage API keys for headless access to the REST API. Each key can have an optional description. Keys are shown once at creation — copy it immediately, as it cannot be retrieved later. diff --git a/docs/guide/using-librislog/search.md b/docs/guide/using-librislog/search.md new file mode 100644 index 00000000..abdfefac --- /dev/null +++ b/docs/guide/using-librislog/search.md @@ -0,0 +1,59 @@ +# Search + +The search box on the **Dashboard**, **Library**, and dedicated `/search` page supports plain-text matching as well as field-specific and negated queries. + +## Field prefixes + +Use `:` to search in a single field. The field prefixes are always in **English**, regardless of the UI language. + +| Prefix | Field | Example | +|--------|-------|---------| +| `author` | Author(s) | `author:Murakami` | +| `title` | Title | `title:"The Hobbit"` | +| `publisher` | Publisher | `publisher:Penguin` | +| `language` | Language | `language:Japanese` | +| `tag` | Tag name | `tag:fantasy` | +| `possession` | Possession status | `possession:owned` | +| `notes` | Private notes | `notes:"to reread"` | +| `description` | Blurb / description | `description:"middle earth"` | + +Use quotes for values that contain spaces: `title:"The Silmarillion"`. + +The `author:` prefix matches **any** author assigned to a book — a book with multiple authors matches if any of them contains the search value. + +### Possession values + +The `possession` prefix matches the exact possession status. Accepted values include: + +- `to_acquire` (or `to acquire`) +- `owned` +- `borrowed` +- `digital` + +Example: `possession:"to acquire"` shows books you want to buy. + +## Negation + +Prefix a term with `-` to exclude matches. + +- `-author:Rowling` +- `-tag:horror` +- `Haushofer -"Die Wand"` + +## Combining terms + +Separate terms with spaces. All terms are combined with **AND**. + +- `author:Murakami -title:Norwegian` — Murakami books except those whose title contains "Norwegian" +- `tag:fantasy possession:owned` — owned fantasy books + +## Plain text + +An unprefixed phrase searches across title, author, publisher, language, notes, description, and tags. It is matched as a phrase, not as individual words. + +- `Marlen Haushofer` — matches the exact phrase across the supported fields +- `Haushofer -Wand` — matches "Haushofer" but excludes books whose fields contain "Wand" + +## Quick reference in the app + +Click the **?** icon next to any search input to open a quick-reference card with the available prefixes and examples. diff --git a/docs/guide/using-librislog/statistics.md b/docs/guide/using-librislog/statistics.md index bc934127..22964082 100644 --- a/docs/guide/using-librislog/statistics.md +++ b/docs/guide/using-librislog/statistics.md @@ -4,7 +4,7 @@ The statistics page provides insights into your reading habits with charts, tota ## Overview Cards -At the top of the statistics page, four key metrics are displayed: +At the top of the statistics page, five key metrics are displayed: ![Statistics Overview](/screenshots/statistics.png) @@ -14,6 +14,7 @@ At the top of the statistics page, four key metrics are displayed: | **Busiest Month** | The month with the most books finished | | **Avg Page Count** | Average number of pages across all books | | **Most Popular Language** | The language you read most (based on book count) | +| **Total Books** | Total number of books, plus how many different authors they're from | ## Distribution Charts diff --git a/docs/index.md b/docs/index.md index 1cf5c247..b2656a85 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,6 +12,9 @@ hero: - theme: brand text: Get Started link: /guide/getting-started + - theme: alt + text: Release Notes + link: /releases - theme: alt text: View on GitHub link: https://github.com/codebude/librislog diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 00000000..20e9088a --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,304 @@ +# Release Notes + +All LibrisLog releases, newest first — what's new, what was fixed, and anything you need to know before upgrading. + +> **Upgrading:** releases are backwards compatible. Database migrations run automatically when the container starts (`alembic upgrade head`), so upgrading is a drop-in replacement. We always recommend [taking a backup](/guide/using-librislog/administration#backup-restore) before an upgrade. + +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). + +## Latest Release + +::: tip ⭐ v1.7.0 — Reading Streaks & Goals +LibrisLog v1.7.0 brings reading streaks and reading goals to the dashboard, a richer book model with multiple authors, a new field-specific search syntax, and a more flexible file import. +::: + +### All releases + +| Version | Date | Type | +|---|---|---| +| [v1.7.0](#v1-7-0-—-reading-streaks-goals) | 2026-08-26 | Feature release | +| [v1.6.0](#v1-6-0-—-reading-progress-possession-tracking) | 2026-08-23 | Feature release | +| [v1.5.2](#v1-5-2-—-maintenance) | 2026-06-22 | Maintenance | +| [v1.5.1](#v1-5-1-—-maintenance) | 2026-06-22 | Maintenance | +| [v1.5.0](#v1-5-0-—-password-reset-usability) | 2026-06-22 | Feature release | +| [v1.4.0](#v1-4-0-—-embeddable-views-arm64) | 2026-06-14 | Feature release | +| [v1.3.1](#v1-3-1-—-maintenance) | 2026-06-09 | Maintenance | +| [v1.3.0](#v1-3-0-—-more-languages) | 2026-06-09 | Feature release | +| [v1.2.2](#v1-2-2-—-maintenance) | 2026-06-08 | Maintenance | +| [v1.2.1](#v1-2-1-—-import-reliability-multi-user-consistency) | 2026-06-08 | Feature release | +| [v1.2.0](#v1-2-0-—-startup-screen-update-checks) | 2026-06-01 | Feature release | +| [v1.1.1](#v1-1-1-—-maintenance) | 2026-06-01 | Maintenance | +| [v1.1.0](#v1-1-0-—-polish-missing-covers) | 2026-05-31 | Feature release | +| [v1.0.0](#v1-0-0-—-initial-release) | 2026-05-28 | Initial release | + +--- + +## v1.7.0 — Reading Streaks & Goals + + + +**Summary:** Adds a gamification section to the dashboard with reading streaks and reading goals, a richer book model with multiple authors, a new field-specific search syntax, and a more flexible file import. + +**Features** +- 👥 **Multiple authors per book** — a book can have any number of authors (normalized per-user author model). The legacy API `author` field is deprecated in favor of the `authors` list +- 🏆 **Reading streaks & goals** — a gamification section on the dashboard shows your current and all-time longest reading streak (with date range) plus playful progress cards for reading goals. Goals (pages/day, pages/month, books/month, books/year) are configured on the profile page and disabled by default; the whole section can be switched off +- 🎲 **Random "Next to Read" suggestions** — the dashboard's suggestion shelf now picks a random selection from your want-to-read list on every visit +- 🔍 **Enhanced search** — field-specific prefixes (`author:`, `title:`, `publisher:`, `tag:`, `language:`, `possession:`, `notes:`, `description:`), quoted phrases, and `-` negation +- 📚 **"All books" library tab** — browse every book regardless of reading status, with the usual search and sort controls +- 📈 **Author statistics card** — total book count and distinct author count on the statistics page +- 📥 **Improved file import** + - New `authors` target field (accepts a plain string or an array; legacy `author` mappings keep working) + - **CSV delimiter** is now user-configurable (default `,`) + - **`date_added`** can be imported — preserves original library dates when migrating from other tools + - Transforms may return **lists** for the `authors`/`tags` targets (e.g. `value.split(';')`) + - Preview renders `authors` and `tags` as JSON arrays +- 📤 **Data export** — `authors` and `tags` export as lists in JSON, round-tripping through the adaptive import +- 🏷️ **Possession naming** — the acquisition/possession field and its search prefix are now consistently called **possession** + +**Bug fixes** +- 📊 Fixed the inverted Top Rated / Worst Rated ordering on the statistics page + +**Breaking changes** +- ⚠️ Creating a book now requires **at least one author** (via `authors` or the legacy `author` field) — API requests without any author are rejected +- ⚠️ The `availability:` search prefix is renamed to `possession:` + +[Compare with v1.6.0](https://github.com/codebude/librislog/compare/v1.6.0...main) + +--- + +## v1.6.0 — Reading Progress & Possession Tracking + + + +**Summary:** Improved reading-progress tracking with automatic cross-view synchronization, a new possession model for tracking what you own, and a wave of UI, statistics, and reliability improvements. + +**Features** +- 📖 Automatic synchronization of reading progress across book cards and the detail view +- 📚 Possession tracking — mark books as owned, borrowed, digitally available, or to acquire +- 📊 Possession information added to the statistics page +- 🏷️ Visual "needs to be acquired" indicators on book cards +- 📝 Improved reading-status transitions, including a start-date prompt and smarter progress-completion handling +- 📅 Timezone-aware progress charts with better visualization +- 🎨 Refined sort selector and other UI improvements +- 🔒 Updated dependencies and applied frontend security patches + +**Breaking changes:** None. + +[Full changelog](https://github.com/codebude/librislog/compare/v1.5.2...v1.6.0) + +--- + +## v1.5.2 — Maintenance + + + +**Summary:** Small maintenance release improving the accuracy of reading-progress visualizations. + +**Bug fixes** +- 📊 Fixed the fallback logic for the start date used in the reading-progress chart in the book detail view +- 🐛 Improved reliability of progress timeline calculations + +**Breaking changes:** None. + +[Full changelog](https://github.com/codebude/librislog/compare/v1.5.1...v1.5.2) + +--- + +## v1.5.1 — Maintenance + + + +**Summary:** Small maintenance release fixing an issue in the book edit drawer. + +**Bug fixes** +- 🐛 Fixed an issue where the edit drawer could fail to open correctly in certain situations + +**Breaking changes:** None. + +[Full changelog](https://github.com/codebude/librislog/compare/v1.5.0...v1.5.1) + +--- + +## v1.5.0 — Password Reset & Usability + + + +**Summary:** Adds password-reset functionality with email support, improves cover imports, and brings several usability and statistics enhancements. + +**Features** +- 🔐 Password reset via email +- 🖼️ Cover imports now follow HTTP redirects automatically +- 📱 Android back-button support for navigation drawers +- 💡 Author and publisher suggestions on the Data Hygiene page +- 📊 Reading-progress charts scaled by actual elapsed time + +**Bug fixes** +- 🐛 Various UI and usability fixes + +**Breaking changes:** None. + +[Full changelog](https://github.com/codebude/librislog/compare/v1.4.0...v1.5.0) + +--- + +## v1.4.0 — Embeddable Views & ARM64 + + + +**Summary:** Introduces embeddable library views, ARM64 Docker support, improved data-hygiene workflows, and auto-generated database documentation. + +**Features** +- 🖼️ New HTML iframe **embed endpoint** for dashboards, homepages, and other applications +- 🏗️ **ARM64 Docker images** for Raspberry Pi and other ARM-based systems +- 🧹 Improved Data Hygiene UX for incomplete or inconsistent metadata +- 📊 Integration documentation for Dashy and Glance +- 🗄️ Auto-generated database schema documentation +- 🧪 Frontend test and type improvements + +**Contributors:** Thank you to **@Jossey28** for adding ARM64 Docker build support. + +**Breaking changes:** None. + +[Full changelog](https://github.com/codebude/librislog/compare/v1.3.1...v1.4.0) + +--- + +## v1.3.1 — Maintenance + + + +**Summary:** Small maintenance release improving the reliability of update notifications. + +**Bug fixes** +- 🔔 Fixed a caching issue affecting the version update indicator + +**Breaking changes:** None. + +[Full changelog](https://github.com/codebude/librislog/compare/v1.3.0...v1.3.1) + +--- + +## v1.3.0 — More Languages + + + +**Summary:** Expands localization support, refines statistics calculations, and improves documentation. + +**Features** +- 🌍 Added **Spanish, French, and Chinese (Simplified)** UI languages with expanded localization coverage +- 📈 Improved pages-per-day statistics calculations +- 📖 Integration documentation (Dashy, Home Assistant) and general documentation improvements + +**Breaking changes:** None. + +[Full changelog](https://github.com/codebude/librislog/compare/v1.2.2...v1.3.0) + +--- + +## v1.2.2 — Maintenance + + + +**Summary:** Small maintenance release focused on Goodreads import data handling. + +**Bug fixes** +- 🐛 Fixed the Goodreads notes transformation during import processing + +**Breaking changes:** None. + +[Full changelog](https://github.com/codebude/librislog/compare/v1.2.1...v1.2.2) + +--- + +## v1.2.1 — Import Reliability & Multi-User Consistency + + + +**Summary:** Focused on import reliability, multi-user data consistency, and quality-of-life improvements. + +**Features** +- 📥 Improved Goodreads import mapping templates and book import handling +- 👥 **Per-user ISBN uniqueness** — the same ISBN can now exist for different users without conflict +- 🌐 Support for a custom documentation domain + +**Bug fixes** +- 🐛 Fixed issues affecting Goodreads imports + +**Contributors:** Thank you to **@badcrc** for their first contribution. + +**Breaking changes:** None. (The ISBN uniqueness change is a database migration and runs automatically on upgrade.) + +[Full changelog](https://github.com/codebude/librislog/compare/v1.2.0...v1.2.1) + +--- + +## v1.2.0 — Startup Screen & Update Checks + + + +**Summary:** Improves the initial user experience and adds automatic update awareness. + +**Features** +- 🚀 New startup loading screen for a smoother launch +- 🔔 **Release update check** that notifies you when a new version is available + +**Breaking changes:** None. + +[Full changelog](https://github.com/codebude/librislog/compare/v1.1.1...v1.2.0) + +--- + +## v1.1.1 — Maintenance + + + +**Summary:** Maintenance release focused on thumbnails, bug fixes, and stability. + +**Bug fixes** +- 🖼️ Improved thumbnail generation and image quality +- 🐛 Fixed several cover and thumbnail handling issues +- 🧪 Test-suite maintenance and documentation build workflow improvements + +**Breaking changes:** None. + +[Full changelog](https://github.com/codebude/librislog/compare/v1.1.0...v1.1.1) + +--- + +## v1.1.0 — Polish & Missing Covers + + + +**Summary:** A refinement release focused on usability, workflow completeness, and overall polish. + +**Features** +- 📚 Improved missing-book-cover workflow for incomplete metadata +- 🌐 Refined translations and i18n coverage +- 🧩 UX and UI improvements across the application +- 📖 Documentation updates + +**Breaking changes:** None. + +[Full changelog](https://github.com/codebude/librislog/compare/v1.0.0...v1.1.0) + +--- + +## v1.0.0 — Initial Release + + + +**Summary:** The first stable release of LibrisLog — a self-hosted, multi-user book tracking web app with full data ownership. + +**Features** +- 📚 Library management with four reading states (Want to Read, Reading, Read, Did Not Finish) +- 📖 Reading-progress tracking with per-book history +- 📊 Statistics dashboard (heatmaps, charts, reading trends) +- 📷 ISBN barcode scanning (browser-based, mobile-friendly) +- 📥 Imports from Goodreads, Open Library, Google Books, and custom CSV/JSON +- 🖼️ Automatic cover-art fetching with manual fallback +- 👥 Multi-user support with roles and optional OIDC login +- 🔌 REST API with OpenAPI documentation +- 🐳 Self-hosted via Docker Compose (SQLite, lightweight setup) +- 🎨 Light/dark themes and responsive UI + +[Full changelog](https://github.com/codebude/librislog/commits/v1.0.0) \ No newline at end of file diff --git a/frontend/e2e/fixtures/pages/add-book-modal.page.ts b/frontend/e2e/fixtures/pages/add-book-modal.page.ts index 006ec7cf..7ccbaf2a 100644 --- a/frontend/e2e/fixtures/pages/add-book-modal.page.ts +++ b/frontend/e2e/fixtures/pages/add-book-modal.page.ts @@ -16,6 +16,7 @@ export class AddBookModalPage { async fillAuthor(author: string) { const input = this.page.locator('[role="dialog"] input[name="author"], [role="dialog"] input[placeholder*="Author"]').first(); await input.fill(author); + await input.press('Enter'); } async clickSave() { diff --git a/frontend/e2e/fixtures/pages/book-drawer.page.ts b/frontend/e2e/fixtures/pages/book-drawer.page.ts index 4ef1ec92..f41f3d38 100644 --- a/frontend/e2e/fixtures/pages/book-drawer.page.ts +++ b/frontend/e2e/fixtures/pages/book-drawer.page.ts @@ -15,6 +15,7 @@ export class BookDrawerPage { async fillAuthor(author: string) { const input = this.page.locator('input[name="author"], input[placeholder*="Author"]').first(); await input.fill(author); + await input.press('Enter'); } async clickSave() { diff --git a/frontend/e2e/fixtures/seed-data.ts b/frontend/e2e/fixtures/seed-data.ts index bf5f28a9..478f4e2d 100644 --- a/frontend/e2e/fixtures/seed-data.ts +++ b/frontend/e2e/fixtures/seed-data.ts @@ -8,6 +8,7 @@ export const SEED_USER = { export interface SeedBook { title: string; author: string; + authors?: string[]; isbn?: string; page_count?: number; reading_status: 'want_to_read' | 'currently_reading' | 'read' | 'did_not_finish'; @@ -27,4 +28,8 @@ export const SEED_BOOKS: SeedBook[] = [ { title: '1984', author: 'George Orwell', isbn: '9780451524935', reading_status: 'read', rating: 5, page_count: 328, date_started: '2024-10-01', date_finished: '2024-10-20' }, { title: 'Brave New World', author: 'Aldous Huxley', isbn: '9780060850524', reading_status: 'read', rating: 4, page_count: 311, date_started: '2024-09-01', date_finished: '2024-09-18' }, { title: 'Atlas Shrugged', author: 'Ayn Rand', reading_status: 'did_not_finish', page_count: 1168 }, + { title: 'Die Fragezeichen', author: 'Christoph Dittert', reading_status: 'want_to_read', page_count: 96 }, + { title: 'Cars & Mercedes', author: 'Jane Driver', reading_status: 'want_to_read', tags: 'cars,audi', page_count: 120 }, + { title: 'Cars Only', author: 'Jane Driver', reading_status: 'want_to_read', tags: 'cars', page_count: 100 }, + { title: 'Good Omens', author: 'Terry Pratchett, Neil Gaiman', authors: ['Terry Pratchett', 'Neil Gaiman'], isbn: '9780060853983', reading_status: 'want_to_read', page_count: 288 }, ]; diff --git a/frontend/e2e/fixtures/seed.api.ts b/frontend/e2e/fixtures/seed.api.ts index cfb81987..52636b57 100644 --- a/frontend/e2e/fixtures/seed.api.ts +++ b/frontend/e2e/fixtures/seed.api.ts @@ -18,13 +18,17 @@ async function getCsrfToken(page: Page): Promise { export async function seedBooks(page: Page, books: SeedBook[]): Promise { for (const book of books) { const csrf = await getCsrfToken(page); - await page.request.post(bookApiPath(), { + const resp = await page.request.post(bookApiPath(), { data: book, headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf, }, }); + // 409 = book already seeded by an earlier test in this suite; that's fine. + if (!resp.ok() && resp.status() !== 409) { + throw new Error(`Seeding book "${book.title}" failed: ${resp.status()} ${await resp.text()}`); + } } } @@ -39,3 +43,40 @@ export async function deleteAllBooks(page: Page): Promise { }); } } + +export async function getBookId(page: Page, title: string): Promise { + const resp = await page.request.get(`${bookApiPath()}?q=${encodeURIComponent(title)}&limit=20`); + const body = await resp.json(); + const books: { id: number; title: string }[] = Array.isArray(body?.books) ? body.books : []; + const book = books.find((b) => b.title === title); + if (!book) throw new Error(`Book "${title}" not found`); + return book.id; +} + +export async function seedProgress(page: Page, bookId: number, pageNo: number): Promise { + const csrf = await getCsrfToken(page); + const resp = await page.request.post(`${bookApiPath()}/${bookId}/progress`, { + data: { page: pageNo }, + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrf, + }, + }); + if (!resp.ok()) { + throw new Error(`Seeding progress failed: ${resp.status()} ${await resp.text()}`); + } +} + +export async function updateProfileSettings(page: Page, data: Record): Promise { + const csrf = await getCsrfToken(page); + const resp = await page.request.patch('/api/profile/settings', { + data, + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrf, + }, + }); + if (!resp.ok()) { + throw new Error(`Updating profile settings failed: ${resp.status()} ${await resp.text()}`); + } +} diff --git a/frontend/e2e/specs/02-dashboard.spec.ts b/frontend/e2e/specs/02-dashboard.spec.ts index 12d20aa7..58e113fd 100644 --- a/frontend/e2e/specs/02-dashboard.spec.ts +++ b/frontend/e2e/specs/02-dashboard.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '@playwright/test'; import { loginViaUi } from '../fixtures/auth.fixture'; -import { seedBooks } from '../fixtures/seed.api'; +import { seedBooks, seedProgress, getBookId, updateProfileSettings } from '../fixtures/seed.api'; import { SEED_USER, SEED_BOOKS } from '../fixtures/seed-data'; test.describe('Dashboard', () => { @@ -47,6 +47,21 @@ test.describe('Dashboard', () => { await expect(page.locator('body')).toContainText(/The Great Gatsby/i); }); + test('2.7 prefixed dashboard search navigates to filtered search page', async ({ page }) => { + await seedBooks(page, SEED_BOOKS); + await page.reload(); + await page.waitForSelector('h1'); + + const searchInput = page.locator('input[type="text"]'); + await searchInput.fill('author:Dittert'); + await page.waitForTimeout(1000); + + await searchInput.press('Enter'); + await expect(page).toHaveURL(/\/search\?q=author%3ADittert/); + await page.waitForTimeout(1000); + await expect(page.locator('body')).toContainText(/Die Fragezeichen/i); + }); + test('2.6 arrow key navigation in dropdown opens book detail dialog on Enter', async ({ page }) => { await seedBooks(page, SEED_BOOKS); await page.reload(); @@ -65,4 +80,65 @@ test.describe('Dashboard', () => { await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }); await expect(page.locator('[role="dialog"]')).toContainText(/The Great Gatsby/i); }); + + test('2.8 gamification card shows current and longest streak', async ({ page }) => { + const card = page.locator('text=Reading Streaks & Goals'); + await expect(card).toBeVisible({ timeout: 5000 }); + await expect(page.locator('text=Current Streak')).toBeVisible(); + await expect(page.locator('text=Longest Streak')).toBeVisible(); + }); + + test('2.9 gamification streak updates after logging reading progress', async ({ page }) => { + await seedBooks(page, SEED_BOOKS); + const bookId = await getBookId(page, 'The Three-Body Problem'); + await seedProgress(page, bookId, 10); + + await page.reload(); + await page.waitForSelector('h1'); + + const streakValue = page.locator('div.stat:has-text("Current Streak") .stat-value'); + await expect(streakValue).toHaveText(/[1-9]/, { timeout: 5000 }); + }); + + test('2.10 gamification goal refreshes after deleting a log entry from the drawer', async ({ page }) => { + await seedBooks(page, SEED_BOOKS); + await updateProfileSettings(page, { goal_pages_per_day_enabled: true, goal_pages_per_day: 20 }); + const bookId = await getBookId(page, 'The Three-Body Problem'); + await seedProgress(page, bookId, 10); + await seedProgress(page, bookId, 20); + + await page.reload(); + await page.waitForSelector('h1'); + + await expect(page.locator('progress[aria-label="10 of 20"]')).toBeVisible({ timeout: 5000 }); + + // Open the detail drawer and delete the newest log entry (page 20). + await page.locator('button:has-text("The Three-Body Problem")').first().click(); + await page.getByRole('button', { name: 'Progress Log' }).click(); + await page.waitForTimeout(300); + + const page20Row = page.locator('tr', { has: page.locator('td.font-mono:has-text("20")') }); + await page20Row.getByRole('button', { name: 'Delete' }).click(); + await page20Row.getByRole('button', { name: 'Confirm?' }).click(); + await page.waitForTimeout(800); + + // Close the log modal and the detail drawer. + await page.locator('[role="dialog"][aria-label="Progress Log"] button[aria-label="Close"]').click(); + await page.locator('button[aria-label="Close"]').last().click(); + await page.waitForTimeout(1500); + + // The pages-per-day goal drops back to 0 without a manual reload. + await expect(page.locator('progress[aria-label="0 of 20"]')).toBeVisible({ timeout: 5000 }); + }); + + test('2.11 disabling gamification hides the section on dashboard', async ({ page }) => { + await updateProfileSettings(page, { gamification_enabled: false }); + await page.reload(); + await page.waitForSelector('h1'); + + await expect(page.locator('text=Reading Streaks & Goals')).toHaveCount(0); + + // Restore the default so later tests keep the section visible. + await updateProfileSettings(page, { gamification_enabled: true }); + }); }); diff --git a/frontend/e2e/specs/03-library-browsing.spec.ts b/frontend/e2e/specs/03-library-browsing.spec.ts index d4f4be17..b1735d7d 100644 --- a/frontend/e2e/specs/03-library-browsing.spec.ts +++ b/frontend/e2e/specs/03-library-browsing.spec.ts @@ -67,18 +67,21 @@ test.describe('Library Browsing', () => { await expect(body).toContainText(/no books|empty/i); }); - test('3.5 manual creation requires availability and persists the selected value', async ({ page }) => { + test('3.5 manual creation requires possession and persists the selected value', async ({ page }) => { await deleteAllBooks(page); const library = new LibraryPage(page); await library.goto(); await page.getByRole('button', { name: '+ Add Book' }).click(); const modal = page.locator('.modal-box'); - const availability = modal.getByRole('combobox', { name: /Availability/ }); + const availability = modal.getByRole('combobox', { name: /Possession/ }); await expect(availability).toHaveValue(''); await modal.getByLabel('Title *').fill('Digital E2E Book'); - await modal.getByRole('searchbox', { name: /Author/ }).fill('E2E Author'); + // The author field is a chip input: type and press Enter to add the author. + const authorInput = modal.getByRole('textbox', { name: /Author/ }); + await authorInput.fill('E2E Author'); + await authorInput.press('Enter'); await modal.getByLabel(/Pages/).fill('200'); await availability.selectOption('digital_access'); await modal.getByRole('button', { name: 'Add Book' }).click(); @@ -88,7 +91,7 @@ test.describe('Library Browsing', () => { expect((await response.json()).books[0].acquisition_status).toBe('digital_access'); }); - test('3.6 filters Want to Read books by availability and marks books to acquire', async ({ page }) => { + test('3.6 filters Want to Read books by possession and marks books to acquire', async ({ page }) => { await deleteAllBooks(page); await createWantToReadBook(page, 'Owned E2E Book', 'owned'); await createWantToReadBook(page, 'Acquire E2E Book', 'to_acquire'); @@ -103,4 +106,36 @@ test.describe('Library Browsing', () => { await expect(page.getByText('Acquire E2E Book')).toBeVisible(); await expect(page.getByText('Owned E2E Book')).not.toBeVisible(); }); + + test('3.7 All Books tab shows every book regardless of status with search and sort', async ({ page }) => { + const library = new LibraryPage(page); + await library.goto(); + + await page.getByRole('tab', { name: /All Books/ }).click(); + await expect(page).toHaveURL(/\/library\?status=all/); + + // All 12 seeded books are shown, spanning every reading status. + await expect(library.getBookCards()).toHaveCount(12); + await expect(page.getByText('The Great Gatsby')).toBeVisible(); + await expect(page.getByText('The Three-Body Problem')).toBeVisible(); + await expect(page.getByText('1984')).toBeVisible(); + await expect(page.getByText('Atlas Shrugged')).toBeVisible(); + + // Smart sort is hidden on the All tab; the sort selects are enabled. + await expect(page.locator('input[name="smart-sort"]')).toHaveCount(0); + await expect(page.locator('select[name="sort-field"]')).toBeEnabled(); + + // Sort by title ascending: "1984" is alphabetically first among the seeds. + await page.locator('select[name="sort-field"]').selectOption('title'); + await page.locator('select[name="sort-order"]').selectOption('asc'); + await expect(page.locator('button.card h2').first()).toHaveText('1984'); + + // Search narrows the All tab results. + const searchInput = page.getByPlaceholder(/Search books/); + await searchInput.fill('Dune'); + await searchInput.press('Enter'); + await expect(page.locator('button.card')).toHaveCount(1); + await expect(page.getByText('Dune')).toBeVisible(); + await expect(page.getByText('1984')).not.toBeVisible(); + }); }); diff --git a/frontend/e2e/specs/04-search-page.spec.ts b/frontend/e2e/specs/04-search-page.spec.ts index b32ba357..8be304fa 100644 --- a/frontend/e2e/specs/04-search-page.spec.ts +++ b/frontend/e2e/specs/04-search-page.spec.ts @@ -77,4 +77,28 @@ test.describe('Search Page', () => { await expect(page).toHaveURL('/dashboard'); }); + + test('4.7 field-prefixed search filters by author', async ({ page }) => { + await page.goto('/search?q=author:Dittert'); + await page.waitForTimeout(1500); + + await expect(page.locator('body')).toContainText(/Die Fragezeichen/i); + await expect(page.locator('body')).not.toContainText(/The Great Gatsby/i); + }); + + test('4.8 negated quoted phrase excludes matching books', async ({ page }) => { + await page.goto('/search?q=%22cars%22%20-%22mercedes%22'); + await page.waitForTimeout(1500); + + await expect(page.locator('body')).toContainText(/Cars Only/i); + await expect(page.locator('body')).not.toContainText(/Cars & Mercedes/i); + }); + + test('4.9 negated tag excludes books tagged audi', async ({ page }) => { + await page.goto('/search?q=tag%3Acars%20-tag%3Aaudi'); + await page.waitForTimeout(1500); + + await expect(page.locator('body')).toContainText(/Cars Only/i); + await expect(page.locator('body')).not.toContainText(/Cars & Mercedes/i); + }); }); diff --git a/frontend/e2e/specs/05-edit-book.spec.ts b/frontend/e2e/specs/05-edit-book.spec.ts index e9a46280..89940acf 100644 --- a/frontend/e2e/specs/05-edit-book.spec.ts +++ b/frontend/e2e/specs/05-edit-book.spec.ts @@ -88,4 +88,41 @@ test.describe('Edit Book', () => { const checkedStar = page.locator('[role="dialog"] input[type="radio"]:checked'); await expect(checkedStar).toHaveAttribute('aria-label', /2 (star|Stern)/); }); + + test('5.4 add a second author in the edit drawer', async ({ page }) => { + const library = new LibraryPage(page); + await library.goto(); + await page.waitForTimeout(1000); + + await library.switchTab('want to read'); + await page.waitForTimeout(500); + + const cards = library.getBookCards(); + await expect(cards.first()).toBeVisible({ timeout: 5000 }); + + // Pick the Dune card (author: Frank Herbert). + const duneCard = cards.filter({ hasText: 'Dune' }).first(); + await expect(duneCard).toBeVisible({ timeout: 5000 }); + await duneCard.click(); + await page.waitForSelector('[role="dialog"]', { timeout: 5000 }); + + const editBtn = page.locator('[role="dialog"] button').filter({ hasText: 'Edit' }); + await editBtn.first().click(); + await page.waitForTimeout(500); + + const authorInput = page.locator('input[name="author"]'); + await expect(authorInput).toBeVisible({ timeout: 5000 }); + + // Add a second author chip by typing and pressing Enter. + await authorInput.fill('Brian Herbert'); + await authorInput.press('Enter'); + await expect(page.locator('text=Brian Herbert')).toBeVisible({ timeout: 5000 }); + + await page.locator('button[type="submit"]').click(); + await page.waitForTimeout(800); + + // Verify the joined author string appears in the library detail. + // Authors are returned sorted alphabetically: Brian Herbert, Frank Herbert. + await expect(page.getByText(/Brian Herbert; Frank Herbert/i)).toBeVisible({ timeout: 5000 }); + }); }); diff --git a/frontend/e2e/specs/06-data-hygiene.spec.ts b/frontend/e2e/specs/06-data-hygiene.spec.ts index 2510ae9e..054a90fa 100644 --- a/frontend/e2e/specs/06-data-hygiene.spec.ts +++ b/frontend/e2e/specs/06-data-hygiene.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '@playwright/test'; import { loginViaUi } from '../fixtures/auth.fixture'; -import { deleteAllBooks } from '../fixtures/seed.api'; +import { deleteAllBooks, seedBooks } from '../fixtures/seed.api'; import { SEED_USER } from '../fixtures/seed-data'; test.describe('Data Hygiene', () => { @@ -9,23 +9,34 @@ test.describe('Data Hygiene', () => { await deleteAllBooks(page); // Seed books with missing attributes. - // author, title, page_count are mandatory in BookCreate, so - // "missing" means empty string for author / 0 for page_count. + // title and page_count are mandatory in BookCreate, so "missing" + // means 0 for page_count. Author is mandatory at creation too, + // so the "Missing Author" book starts with a placeholder author + // whose authors list is cleared afterwards via PATCH (updates + // allow emptying the authors list). const books = [ { title: 'Complete Book', author: 'Test Author', isbn: '9780000000001', publisher: 'Test Pub', page_count: 200, reading_status: 'want_to_read' as const }, - { title: 'Missing Author', author: '', isbn: '9780000000002', publisher: 'Test Pub', page_count: 150, reading_status: 'want_to_read' as const }, + { title: 'Missing Author', author: 'Placeholder Author', isbn: '9780000000002', publisher: 'Test Pub', page_count: 150, reading_status: 'want_to_read' as const }, { title: 'Missing ISBN', author: 'No ISBN', page_count: 300, reading_status: 'want_to_read' as const }, { title: 'Missing Page Count', author: 'Page Author', page_count: 0, reading_status: 'want_to_read' as const }, { title: 'Missing Publisher', author: 'Pub Missing', isbn: '9780000000003', page_count: 250, reading_status: 'want_to_read' as const }, ]; - - for (const book of books) { - const csrfResp = await page.request.get('/api/auth/csrf'); - const { csrf_token } = await csrfResp.json(); - await page.request.post('/api/books', { - data: book, - headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf_token }, - }); + await seedBooks(page, books); + + const csrfResp = await page.request.get('/api/auth/csrf'); + const { csrf_token } = await csrfResp.json(); + const listResp = await page.request.get('/api/books?limit=200'); + const { books: created } = await listResp.json(); + const missingAuthor = (created as Array<{ id: number; title: string }>).find( + (book) => book.title === 'Missing Author' + ); + if (!missingAuthor) throw new Error('Seeded book "Missing Author" not found.'); + const patchResp = await page.request.patch(`/api/books/${missingAuthor.id}`, { + data: { authors: [] }, + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf_token }, + }); + if (!patchResp.ok()) { + throw new Error(`Clearing authors failed: ${patchResp.status()} ${await patchResp.text()}`); } }); diff --git a/frontend/e2e/specs/08-statistics.spec.ts b/frontend/e2e/specs/08-statistics.spec.ts index 225aeb77..1fa43a5d 100644 --- a/frontend/e2e/specs/08-statistics.spec.ts +++ b/frontend/e2e/specs/08-statistics.spec.ts @@ -33,4 +33,24 @@ test.describe('Statistics', () => { await expect(page.getByText('The Great Gatsby').first()).toBeVisible(); await expect(page.getByText('Brave New World').first()).toBeVisible(); }); + + test('8.3 top authors reflect multi-author books', async ({ page }) => { + // Give Frank Herbert more books (Good Omens already counts Pratchett/Gaiman). + await seedBooks(page, [ + { title: 'Children of Dune', author: 'Frank Herbert', reading_status: 'read', rating: 4, page_count: 408, date_started: '2024-01-01', date_finished: '2024-01-20' }, + { title: 'God Emperor of Dune', author: 'Frank Herbert', reading_status: 'read', rating: 4, page_count: 496, date_started: '2024-02-01', date_finished: '2024-02-20' }, + ]); + + await page.goto('/statistics'); + await page.waitForTimeout(2000); + + // Frank Herbert now has the most books and must be the top author. + await expect(page.getByText(/Top Authors|Beliebteste Autoren/i)).toBeVisible(); + const topAuthorsCard = page + .locator('.card') + .filter({ has: page.getByRole('heading', { name: /Top Authors|Beliebteste Autoren/i }) }); + const frankCard = topAuthorsCard.locator('.rounded-xl').filter({ hasText: 'Frank Herbert' }).first(); + await expect(frankCard).toBeVisible({ timeout: 5000 }); + await expect(frankCard.getByText('#1', { exact: true })).toBeVisible(); + }); }); diff --git a/frontend/e2e/specs/09-data-import.spec.ts b/frontend/e2e/specs/09-data-import.spec.ts index 6364d8f5..0c6c8474 100644 --- a/frontend/e2e/specs/09-data-import.spec.ts +++ b/frontend/e2e/specs/09-data-import.spec.ts @@ -39,7 +39,7 @@ test.describe('Data Import', () => { await expect(page.locator('select[name="mapping-target-title"]')).toBeVisible({ timeout: 10000 }); await page.locator('select[name="mapping-target-title"]').selectOption('title'); - await page.locator('select[name="mapping-target-author"]').selectOption('author'); + await page.locator('select[name="mapping-target-authors"]').selectOption('author'); await page.locator('select[name="mapping-target-isbn"]').selectOption('isbn'); await page.locator('select[name="mapping-target-page_count"]').selectOption('pages'); await page.locator('select[name="mapping-target-reading_status"]').selectOption('status'); @@ -95,7 +95,7 @@ test.describe('Data Import', () => { await expect(page.locator('select[name="mapping-target-title"]')).toBeVisible({ timeout: 10000 }); await page.locator('select[name="mapping-target-title"]').selectOption('title'); - await page.locator('select[name="mapping-target-author"]').selectOption('author'); + await page.locator('select[name="mapping-target-authors"]').selectOption('author'); await page.locator('select[name="mapping-target-isbn"]').selectOption('isbn'); await page.locator('select[name="mapping-target-page_count"]').selectOption('pages'); await page.locator('select[name="mapping-target-reading_status"]').selectOption('status'); @@ -124,7 +124,7 @@ test.describe('Data Import', () => { await page.waitForTimeout(2000); await page.locator('select[name="mapping-target-title"]').selectOption('title'); - await page.locator('select[name="mapping-target-author"]').selectOption('author'); + await page.locator('select[name="mapping-target-authors"]').selectOption('author'); await page.locator('select[name="mapping-target-isbn"]').selectOption('isbn'); await page.locator('select[name="mapping-target-page_count"]').selectOption('pages'); await page.locator('select[name="mapping-target-reading_status"]').selectOption('status'); @@ -153,7 +153,7 @@ test.describe('Data Import', () => { await page.waitForTimeout(2000); await page.locator('select[name="mapping-target-title"]').selectOption('title'); - await page.locator('select[name="mapping-target-author"]').selectOption('author'); + await page.locator('select[name="mapping-target-authors"]').selectOption('author'); await page.locator('select[name="mapping-target-isbn"]').selectOption('isbn'); await page.locator('select[name="mapping-target-page_count"]').selectOption('pages'); await page.locator('select[name="mapping-target-reading_status"]').selectOption('status'); @@ -180,4 +180,99 @@ test.describe('Data Import', () => { expect(books).toHaveLength(1); expect(books[0].acquisition_status).toBe('to_acquire'); }); + + test('9.6 JSON author array maps to multiple authors', async ({ page }) => { + await deleteAllBooks(page); + await page.goto('/data?tab=import'); + await page.waitForTimeout(1000); + + const JSON_DATA = JSON.stringify([ + { + title: 'Good Omens', + authors: ['Terry Pratchett', 'Neil Gaiman'], + isbn: '9780060853983', + pages: 288, + status: 'want_to_read', + availability: 'owned', + }, + ]); + + await page.locator('input[type="file"]').setInputFiles({ + name: 'test-books.json', + mimeType: 'application/json', + buffer: Buffer.from(JSON_DATA), + }); + + await page.locator('button').filter({ hasText: 'Parse file' }).click(); + await page.waitForTimeout(2000); + + await page.locator('select[name="mapping-target-title"]').selectOption('title'); + await page.locator('select[name="mapping-target-authors"]').selectOption('authors'); + await page.locator('select[name="mapping-target-isbn"]').selectOption('isbn'); + await page.locator('select[name="mapping-target-page_count"]').selectOption('pages'); + await page.locator('select[name="mapping-target-reading_status"]').selectOption('status'); + await page.locator('select[name="mapping-target-acquisition_status"]').selectOption('availability'); + + await page.locator('button').filter({ hasText: 'Generate' }).click(); + await page.waitForTimeout(2000); + + await page.locator('button').filter({ hasText: 'Simulate' }).click(); + await page.waitForTimeout(2000); + + await expect(page.locator('body')).toContainText('Validation passed.', { timeout: 10000 }); + + await page.locator('button.btn-secondary.btn-sm').filter({ hasText: 'Import now' }).click(); + await page.locator('dialog.modal-open .btn-secondary').filter({ hasText: 'Import now' }).waitFor({ state: 'visible', timeout: 5000 }); + await page.locator('dialog.modal-open .btn-secondary').filter({ hasText: 'Import now' }).click(); + await page.waitForTimeout(2000); + + const response = await page.request.get('/api/books?q=Good%20Omens'); + const books = (await response.json()).books; + expect(books).toHaveLength(1); + expect(books[0].authors).toEqual(['Neil Gaiman', 'Terry Pratchett']); + }); + + test('9.7 CSV author cell with a comma stays a single author', async ({ page }) => { + await deleteAllBooks(page); + await page.goto('/data?tab=import'); + await page.waitForTimeout(1000); + + const CSV_COMMA = `title,author,isbn,pages,status,availability +"Fahrenheit 451","Bradbury, Ray","9781451673319",249,want_to_read,owned`; + + await page.locator('input[type="file"]').setInputFiles({ + name: 'test-books.csv', + mimeType: 'text/csv', + buffer: Buffer.from(CSV_COMMA), + }); + + await page.locator('button').filter({ hasText: 'Parse file' }).click(); + await page.waitForTimeout(2000); + + await page.locator('select[name="mapping-target-title"]').selectOption('title'); + await page.locator('select[name="mapping-target-authors"]').selectOption('author'); + await page.locator('select[name="mapping-target-isbn"]').selectOption('isbn'); + await page.locator('select[name="mapping-target-page_count"]').selectOption('pages'); + await page.locator('select[name="mapping-target-reading_status"]').selectOption('status'); + await page.locator('select[name="mapping-target-acquisition_status"]').selectOption('availability'); + + await page.locator('button').filter({ hasText: 'Generate' }).click(); + await page.waitForTimeout(2000); + + await page.locator('button').filter({ hasText: 'Simulate' }).click(); + await page.waitForTimeout(2000); + + await expect(page.locator('body')).toContainText('Validation passed.', { timeout: 10000 }); + + await page.locator('button.btn-secondary.btn-sm').filter({ hasText: 'Import now' }).click(); + await page.locator('dialog.modal-open .btn-secondary').filter({ hasText: 'Import now' }).waitFor({ state: 'visible', timeout: 5000 }); + await page.locator('dialog.modal-open .btn-secondary').filter({ hasText: 'Import now' }).click(); + await page.waitForTimeout(2000); + + const response = await page.request.get('/api/books?q=Fahrenheit'); + const books = (await response.json()).books; + expect(books).toHaveLength(1); + expect(books[0].authors).toEqual(['Bradbury, Ray']); + expect(books[0].author).toBe('Bradbury, Ray'); + }); }); diff --git a/frontend/e2e/specs/11-profile.spec.ts b/frontend/e2e/specs/11-profile.spec.ts index 8815933d..a810b5b7 100644 --- a/frontend/e2e/specs/11-profile.spec.ts +++ b/frontend/e2e/specs/11-profile.spec.ts @@ -113,4 +113,43 @@ test.describe('Profile', () => { // Now the embed tokens section should be visible await expect(page.locator('#section-embed-tokens')).toBeVisible(); }); + + test('11.7 enable and configure reading goals', async ({ page }) => { + await page.goto('/profile'); + await page.waitForTimeout(1000); + + await page.locator('#section-goals').scrollIntoViewIfNeeded(); + await page.waitForTimeout(500); + + await page.locator('input[name="goal-pages-per-day-enabled"]').check(); + await page.locator('input[name="goal-pages-per-day"]').fill('25'); + await page.locator('input[name="goal-books-per-year-enabled"]').check(); + + await page.locator('#section-goals button[class*="btn-primary"]').click(); + await page.waitForTimeout(1000); + + const alert = page.locator('#section-goals .alert'); + await expect(alert).toBeVisible({ timeout: 5000 }); + + await page.reload(); + await page.waitForTimeout(1000); + + await expect(page.locator('input[name="goal-pages-per-day-enabled"]')).toBeChecked(); + await expect(page.locator('input[name="goal-pages-per-day"]')).toHaveValue('25'); + await expect(page.locator('input[name="goal-books-per-year-enabled"]')).toBeChecked(); + }); + + test('11.8 goal input is disabled until enabled', async ({ page }) => { + await page.goto('/profile'); + await page.waitForTimeout(1000); + + await page.locator('#section-goals').scrollIntoViewIfNeeded(); + await page.waitForTimeout(500); + + const input = page.locator('input[name="goal-books-per-month"]'); + await expect(input).toBeDisabled(); + + await page.locator('input[name="goal-books-per-month-enabled"]').check(); + await expect(input).toBeEnabled(); + }); }); diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts index f6491ae4..ff1fb970 100644 --- a/frontend/src/lib/api.test.ts +++ b/frontend/src/lib/api.test.ts @@ -219,3 +219,69 @@ describe('api.profile.embedTokens', () => { expect(fetchMock.mock.calls[0][0]).toContain('/profile/embed-tokens/1'); }); }); + +describe('api.statistics.gamification', () => { + afterEach(() => { + apiKey.set(null); + csrfToken.set(null); + vi.restoreAllMocks(); + }); + + it('calls GET /statistics/gamification', async () => { + apiKey.set('test-key'); + csrfToken.set('test-csrf'); + + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + headers: { get: () => 'application/json' }, + json: async () => ({ + enabled: true, + current_streak: 2, + longest_streak: 5, + longest_streak_start: '2026-01-01', + longest_streak_end: '2026-01-05', + goals: [ + { type: 'pages_per_day', target: 20, current: 15, reached: false } + ] + }) + } as unknown as Response); + + const data = await api.statistics.gamification(); + expect(fetchMock.mock.calls[0][0]).toContain('/statistics/gamification'); + expect(data.current_streak).toBe(2); + expect(data.goals[0].type).toBe('pages_per_day'); + }); + + it('updateSettings sends goal fields', async () => { + apiKey.set('test-key'); + csrfToken.set('test-csrf'); + + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + headers: { get: () => 'application/json' }, + json: async () => ({ + user_id: 1, + language: 'en', + timezone: 'UTC', + theme: 'light', + custom_theme: null, + goal_pages_per_day_enabled: true, + goal_pages_per_day: 25, + goal_pages_per_month_enabled: false, + goal_pages_per_month: 300, + goal_books_per_month_enabled: false, + goal_books_per_month: 2, + goal_books_per_year_enabled: true, + goal_books_per_year: 25, + gamification_enabled: true + }) + } as unknown as Response); + + await api.profile.updateSettings({ goal_pages_per_day_enabled: true, goal_pages_per_day: 25 }); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe('PATCH'); + expect(fetchMock.mock.calls[0][0]).toContain('/profile/settings'); + const body = JSON.parse(String(init.body)); + expect(body).toMatchObject({ goal_pages_per_day_enabled: true, goal_pages_per_day: 25 }); + }); +}); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b6d25b04..3dcdd415 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -24,6 +24,7 @@ import type { BookProgress, DailyPagesResponse, DashboardQuote, + GamificationResponse, StatisticsResponse, LibraryStats, ReadingProgressEntry, @@ -161,7 +162,7 @@ export const api = { return request('/profile/settings'); }, - updateSettings(data: { language?: string; timezone?: string; theme?: string; custom_theme?: string | null }): Promise { + updateSettings(data: Partial): Promise { return request('/profile/settings', { method: 'PATCH', body: JSON.stringify(data) @@ -264,6 +265,10 @@ export const api = { getPagesPerDay(days: number = 365): Promise { const qs = new URLSearchParams({ days: String(days) }); return request(`/statistics/pages-per-day?${qs}`); + }, + + gamification(): Promise { + return request('/statistics/gamification'); } }, @@ -529,13 +534,14 @@ export const api = { return res.blob(); }, - async parseImportFile(file: File): Promise { + async parseImportFile(file: File, delimiter = ','): Promise { const headers: Record = { ...authHeaders() }; const csrf = get(csrfToken); if (csrf) headers['X-CSRF-Token'] = csrf; const form = new FormData(); form.append('file', file); + form.append('delimiter', delimiter); const res = await fetch(`${BASE}/data/import/parse`, { method: 'POST', headers, diff --git a/frontend/src/lib/components/AddBookModal.svelte b/frontend/src/lib/components/AddBookModal.svelte index e2a67784..9bb46695 100644 --- a/frontend/src/lib/components/AddBookModal.svelte +++ b/frontend/src/lib/components/AddBookModal.svelte @@ -28,7 +28,7 @@ // Manual form state let title = $state(''); let subtitle = $state(''); - let author = $state(''); + let authors = $state([]); let isbn = $state(''); let publisher = $state(''); let published_year = $state(''); @@ -46,7 +46,7 @@ function reset() { title = ''; subtitle = ''; - author = ''; + authors = []; isbn = ''; publisher = ''; published_year = ''; @@ -64,7 +64,7 @@ async function submitManual() { if (!title.trim()) return; - if (!author.trim()) return; + if (authors.length === 0) return; if (!page_count) return; if (!acquisitionStatus) return; submitting = true; @@ -72,7 +72,7 @@ const book = await api.books.create({ title: title.trim(), subtitle: subtitle || null, - author: author.trim(), + authors, isbn: isbn || null, publisher: publisher || null, published_year: published_year ? parseInt(published_year) : null, @@ -149,11 +149,12 @@
- api.books.suggestions.authors(q)} />

{book.title}

{#if book.author} -

{book.author}

+

{formatAuthors(book.authors, book.author)}

{/if} {#if book.reading_status === 'want_to_read' && book.acquisition_status === 'to_acquire'} diff --git a/frontend/src/lib/components/BookCard.test.ts b/frontend/src/lib/components/BookCard.test.ts index b7f43aec..c69fdfce 100644 --- a/frontend/src/lib/components/BookCard.test.ts +++ b/frontend/src/lib/components/BookCard.test.ts @@ -9,6 +9,7 @@ function mockBook(overrides?: Partial): Book { title: 'The Test Book', subtitle: null, author: 'Jane Tester', + authors: ['Jane Tester'], isbn: '9781234567890', cover_url: 'http://localhost/cover.jpg', publisher: null, diff --git a/frontend/src/lib/components/BookDetailDialog.svelte b/frontend/src/lib/components/BookDetailDialog.svelte index 31d48ad6..291c9c72 100644 --- a/frontend/src/lib/components/BookDetailDialog.svelte +++ b/frontend/src/lib/components/BookDetailDialog.svelte @@ -7,6 +7,7 @@ import { api } from '$lib/api'; import { toasts } from '$lib/toasts'; import { formatLanguageCode } from '$lib/utils/language'; + import { formatAuthors } from '$lib/utils/authors'; import StarRating from './StarRating.svelte'; import { Line } from 'svelte-chartjs'; import { X } from '@lucide/svelte'; @@ -396,7 +397,7 @@
-
{book.author ?? '-'}
+
{formatAuthors(book.authors, book.author ?? '-')}
{$_(STATUS_LABEL_KEYS[book.reading_status])}
diff --git a/frontend/src/lib/components/BookDetailDialog.test.ts b/frontend/src/lib/components/BookDetailDialog.test.ts index 806b42c4..d91c7bb9 100644 --- a/frontend/src/lib/components/BookDetailDialog.test.ts +++ b/frontend/src/lib/components/BookDetailDialog.test.ts @@ -42,6 +42,7 @@ const mockBook = { title: 'Test Book', subtitle: 'A subtitle', author: 'Test Author', + authors: ['Test Author'], isbn: '9781234567890', publisher: 'Test Publisher', published_year: 2024, diff --git a/frontend/src/lib/components/BookDrawer.svelte b/frontend/src/lib/components/BookDrawer.svelte index 25e5e0fc..455d198a 100644 --- a/frontend/src/lib/components/BookDrawer.svelte +++ b/frontend/src/lib/components/BookDrawer.svelte @@ -47,7 +47,7 @@ // Editable fields let title = $state(''); let subtitle = $state(''); - let author = $state(''); + let authors = $state([]); let isbn = $state(''); let notes = $state(''); let blurb = $state(''); @@ -87,7 +87,7 @@ if (book) { title = book.title; subtitle = book.subtitle ?? ''; - author = book.author ?? ''; + authors = [...(book.authors ?? [])]; isbn = book.isbn ?? ''; notes = book.notes ?? ''; blurb = book.blurb ?? ''; @@ -112,7 +112,7 @@ const payload: Partial = { title, subtitle: subtitle || null, - author: author.trim(), + authors, isbn: isbn || null, publisher: publisher || null, published_year: published_year ? parseInt(published_year, 10) : null, @@ -208,7 +208,7 @@ async function save({ skipAutoDateStarted = false } = {}) { if (!book) return; - if (!author.trim()) { + if (authors.length === 0) { toasts.add($_('error.authorRequired'), 'error'); return; } @@ -333,7 +333,7 @@ ]; const coverSearchUrl = $derived.by(() => { - const query = `${title} ${author}`.trim(); + const query = `${title} ${authors.join(' ')}`.trim(); return `https://www.google.com/search?q=${encodeURIComponent(query)}&udm=2&tbs=isz:l`; }); @@ -416,11 +416,12 @@ - api.books.suggestions.authors(q)} /> diff --git a/frontend/src/lib/components/BookDrawer.test.ts b/frontend/src/lib/components/BookDrawer.test.ts index ac90e89c..397c6c92 100644 --- a/frontend/src/lib/components/BookDrawer.test.ts +++ b/frontend/src/lib/components/BookDrawer.test.ts @@ -54,6 +54,7 @@ const mockBook = { title: 'Test Book', subtitle: 'Subtitle', author: 'Author Name', + authors: ['Author Name'], isbn: '9781234567890', publisher: 'Publisher', published_year: 2024, diff --git a/frontend/src/lib/components/BookListItem.svelte b/frontend/src/lib/components/BookListItem.svelte index f92477c0..1f9d05d8 100644 --- a/frontend/src/lib/components/BookListItem.svelte +++ b/frontend/src/lib/components/BookListItem.svelte @@ -3,6 +3,7 @@ import { _ } from '$lib/i18n'; import StarRating from './StarRating.svelte'; import { ShoppingCart } from '@lucide/svelte'; + import { formatAuthors } from '$lib/utils/authors'; let { book, @@ -52,7 +53,7 @@

{book.title}

{#if book.author} -

{book.author}

+

{formatAuthors(book.authors, book.author)}

{/if} {#if book.reading_status === 'want_to_read' && book.acquisition_status === 'to_acquire'} diff --git a/frontend/src/lib/components/DataImport.svelte b/frontend/src/lib/components/DataImport.svelte index 979bc684..1bdd0f4f 100644 --- a/frontend/src/lib/components/DataImport.svelte +++ b/frontend/src/lib/components/DataImport.svelte @@ -15,6 +15,8 @@ } from '$lib/types'; let selectedFile = $state(null); + let delimiter = $state(','); + const isCsvFile = $derived(selectedFile?.name.toLowerCase().endsWith('.csv') ?? false); let parsing = $state(false); let parsed = $state(null); let mapping = $state>({}); @@ -88,13 +90,14 @@ validation = null; importResult = null; try { - parsed = await api.data.parseImportFile(selectedFile); + parsed = await api.data.parseImportFile(selectedFile, delimiter); const suggest = await api.data.suggestMapping(parsed.file_id); mapping = suggest.suggested_mapping; dbFields = suggest.db_fields; await refreshMappings(); } catch (err: unknown) { - toasts.add(err instanceof Error ? err.message : $_('data.import.errors.parseFailed'), 'error'); + const message = err instanceof Error ? err.message : $_('data.import.errors.parseFailed'); + toasts.add(message.startsWith('error.') ? $_(message) : message, 'error'); } finally { parsing = false; } @@ -351,6 +354,23 @@ {parsing ? $_('data.import.parsing') : $_('data.import.parse')}
+ {#if isCsvFile} + + {/if} {#if parsed}

{$_('data.import.fileSummary', { values: { rows: parsed.row_count, fields: parsed.source_fields.length } })} diff --git a/frontend/src/lib/components/DataImport.test.ts b/frontend/src/lib/components/DataImport.test.ts index 8f62c411..df50591b 100644 --- a/frontend/src/lib/components/DataImport.test.ts +++ b/frontend/src/lib/components/DataImport.test.ts @@ -11,8 +11,8 @@ const mockParseImportFile = vi.fn(async (_file: File) => ({ row_count: 1 })); const mockSuggestMapping = vi.fn(async (_fileId: string) => ({ - suggested_mapping: { title: 'Book Title', author: 'Author Name', isbn: 'ISBN' }, - db_fields: ['title', 'author', 'isbn', 'publisher', 'page_count'] + suggested_mapping: { title: 'Book Title', authors: 'Author Name', isbn: 'ISBN' }, + db_fields: ['title', 'authors', 'isbn', 'publisher', 'page_count'] })); const mockValidateImport = vi.fn(async (_params: unknown) => ({ valid: true, diff --git a/frontend/src/lib/components/GamificationCard.svelte b/frontend/src/lib/components/GamificationCard.svelte new file mode 100644 index 00000000..914d665a --- /dev/null +++ b/frontend/src/lib/components/GamificationCard.svelte @@ -0,0 +1,135 @@ + + +

+
+

+ + + {$_('dashboard.gamificationTitle')} + +

+ + {#if loading} +
+ {:else} +
+
+
+ +
+
{$_('dashboard.currentStreak')}
+
{currentStreak}
+
{$_('dashboard.currentStreakHint')}
+
+ +
+
+ +
+
{$_('dashboard.longestStreak')}
+
{longestStreak}
+
{dateRangeLabel(longestStreakStart, longestStreakEnd)}
+
+
+ + {#if goals.length > 0} +
+
+ {#each goals as goal (goal.type)} + {@const Icon = goalIcon(goal.type)} +
+
+ + + {$_(GOAL_LABEL_KEYS[goal.type])} + + {#if goal.reached} + + + {$_('dashboard.goalReached')} + + {/if} +
+
+ {goal.current} + {$_('dashboard.goalOf', { values: { target: goal.target } })} +
+ +
+ {$_('dashboard.goalProgress', { values: { current: goal.current, target: goal.target } })} +
+
+ {/each} +
+ {/if} + {/if} +
+
\ No newline at end of file diff --git a/frontend/src/lib/components/GamificationCard.test.ts b/frontend/src/lib/components/GamificationCard.test.ts new file mode 100644 index 00000000..c6f4f151 --- /dev/null +++ b/frontend/src/lib/components/GamificationCard.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import GamificationCard from './GamificationCard.svelte'; +import type { GoalProgress } from '$lib/types'; + +function goal(overrides?: Partial): GoalProgress { + return { + type: 'pages_per_day', + target: 20, + current: 15, + reached: false, + ...overrides + }; +} + +const baseProps = { + currentStreak: 0, + longestStreak: 0, + longestStreakStart: null, + longestStreakEnd: null, + goals: [] as GoalProgress[] +}; + +describe('GamificationCard', () => { + it('shows the current streak and longest streak', () => { + render(GamificationCard, { + props: { + ...baseProps, + currentStreak: 3, + longestStreak: 12, + longestStreakStart: '2026-01-01', + longestStreakEnd: '2026-01-12' + } + }); + + expect(screen.getByText('Reading Streaks & Goals')).toBeInTheDocument(); + expect(screen.getByText('Current Streak')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('Longest Streak')).toBeInTheDocument(); + expect(screen.getByText('12')).toBeInTheDocument(); + expect(screen.getByText('2026-01-01 – 2026-01-12')).toBeInTheDocument(); + }); + + it('shows no date range subtitle when there is no longest streak', () => { + render(GamificationCard, { props: baseProps }); + expect(screen.queryByText(/2026-01-01/)).not.toBeInTheDocument(); + }); + + it('renders goal cards with progress when goals are provided', () => { + render(GamificationCard, { + props: { + ...baseProps, + goals: [goal({ current: 15, target: 20, reached: false })] + } + }); + + expect(screen.getByText('Pages per Day')).toBeInTheDocument(); + expect(screen.getByText('15')).toBeInTheDocument(); + expect(screen.getByText('of 20')).toBeInTheDocument(); + expect(screen.getByText('15 of 20')).toBeInTheDocument(); + expect(screen.queryByText('Goal reached')).not.toBeInTheDocument(); + }); + + it('shows a success badge when a goal is reached', () => { + render(GamificationCard, { + props: { + ...baseProps, + goals: [ + goal({ type: 'books_per_month', current: 2, target: 2, reached: true }) + ] + } + }); + + expect(screen.getByText('Books per Month')).toBeInTheDocument(); + expect(screen.getByText('Goal reached')).toBeInTheDocument(); + }); + + it('renders multiple goal cards', () => { + render(GamificationCard, { + props: { + ...baseProps, + goals: [ + goal({ current: 15, target: 20 }), + goal({ type: 'pages_per_month', current: 120, target: 300 }), + goal({ type: 'books_per_year', current: 25, target: 25, reached: true }) + ] + } + }); + + expect(screen.getByText('Pages per Day')).toBeInTheDocument(); + expect(screen.getByText('Pages per Month')).toBeInTheDocument(); + expect(screen.getByText('Books per Year')).toBeInTheDocument(); + expect(screen.getByText('Goal reached')).toBeInTheDocument(); + }); + + it('does not render the goal grid when there are no goals', () => { + render(GamificationCard, { props: baseProps }); + expect(screen.queryByText('Pages per Day')).not.toBeInTheDocument(); + expect(screen.queryByText('Goal reached')).not.toBeInTheDocument(); + }); + + it('shows a loading indicator while loading', () => { + render(GamificationCard, { props: { ...baseProps, loading: true } }); + expect(document.querySelector('.loading')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/components/ImportMappingEditor.svelte b/frontend/src/lib/components/ImportMappingEditor.svelte index abf8d89a..953eb22d 100644 --- a/frontend/src/lib/components/ImportMappingEditor.svelte +++ b/frontend/src/lib/components/ImportMappingEditor.svelte @@ -16,7 +16,7 @@ onChange: (mapping: Record) => void; } = $props(); - const MANDATORY_FIELDS = ['title', 'author', 'page_count', 'acquisition_status']; + const MANDATORY_FIELDS = ['title', 'authors', 'page_count', 'acquisition_status']; let transformOpen = $state>({}); function updateSource(target: string, source: string) { @@ -107,6 +107,12 @@ {$_('data.import.coverUrlHint')}
{/if} + {#if dbField === 'authors'} +
+ + {$_('data.import.authorsHint')} +
+ {/if}

{book.title}

-

{book.author ?? '-'}

+

{formatAuthors(book.authors, book.author ?? '-')}

{#each Array(maxRating) as _, i} {i < book.rating ? '★' : '☆'} diff --git a/frontend/src/lib/components/RatedBooksSection.test.ts b/frontend/src/lib/components/RatedBooksSection.test.ts index 3860e1ba..1cdcef35 100644 --- a/frontend/src/lib/components/RatedBooksSection.test.ts +++ b/frontend/src/lib/components/RatedBooksSection.test.ts @@ -65,6 +65,7 @@ function makeBook(id: number, rating: number, title?: string): TopRatedBook { book_id: id, title: title ?? `Book ${id}`, author: `Author ${id}`, + authors: [`Author ${id}`], rating, reading_status: 'read', cover_url: `http://example.com/cover${id}.jpg` diff --git a/frontend/src/lib/components/SearchHelp.svelte b/frontend/src/lib/components/SearchHelp.svelte new file mode 100644 index 00000000..d058c455 --- /dev/null +++ b/frontend/src/lib/components/SearchHelp.svelte @@ -0,0 +1,81 @@ + + +
+ + + {#if open} + + + {/if} +
\ No newline at end of file diff --git a/frontend/src/lib/components/SearchHelp.test.ts b/frontend/src/lib/components/SearchHelp.test.ts new file mode 100644 index 00000000..9bac602a --- /dev/null +++ b/frontend/src/lib/components/SearchHelp.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/svelte'; +import SearchHelp from './SearchHelp.svelte'; + +describe('SearchHelp', () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('renders the help trigger button', () => { + render(SearchHelp); + expect(screen.getByRole('button', { name: 'Search syntax' })).toBeInTheDocument(); + }); + + it('opens the help panel on click', async () => { + render(SearchHelp); + const trigger = screen.getByRole('button', { name: 'Search syntax' }); + + await fireEvent.click(trigger); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Use field prefixes to search in a specific field. Prefixes are always English.')).toBeInTheDocument(); + }); + + it('displays the hardcoded English prefixes', async () => { + render(SearchHelp); + await fireEvent.click(screen.getByRole('button', { name: 'Search syntax' })); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveTextContent('author'); + expect(dialog).toHaveTextContent('publisher'); + expect(dialog).toHaveTextContent('title'); + expect(dialog).toHaveTextContent('tag'); + expect(dialog).toHaveTextContent('language'); + expect(dialog).toHaveTextContent('possession'); + expect(dialog).toHaveTextContent('notes'); + expect(dialog).toHaveTextContent('description'); + }); + + it('displays the quoted-value and negation examples', async () => { + render(SearchHelp); + await fireEvent.click(screen.getByRole('button', { name: 'Search syntax' })); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveTextContent('author:"Christoph Dittert"'); + expect(dialog).toHaveTextContent('tag:cars -tag:audi'); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/components/TagInput.svelte b/frontend/src/lib/components/TagInput.svelte index 6fb67d3f..ad3fe682 100644 --- a/frontend/src/lib/components/TagInput.svelte +++ b/frontend/src/lib/components/TagInput.svelte @@ -3,18 +3,28 @@ let { value = $bindable(''), + values = $bindable(undefined), name = '', disabled = false, maxTagsCount, - fetchSuggestions + fetchSuggestions, + label, + placeholder, + hint }: { value?: string; + values?: string[]; name?: string; disabled?: boolean; maxTagsCount?: number; fetchSuggestions?: (query: string) => Promise; + label?: string; + placeholder?: string; + hint?: string; } = $props(); + const listMode = $derived(values !== undefined); + let inputValue = $state(''); let inputEl: HTMLInputElement | undefined = $state(); let suggestions: string[] = $state([]); @@ -24,48 +34,82 @@ let debounceTimer: ReturnType | undefined = $state(); let dropdownStyle = $state(''); - const tags = $derived.by(() => - value + const chips = $derived.by(() => { + if (listMode) return values ?? []; + return value .split(',') .map((tag) => tag.trim()) - .filter(Boolean) - ); + .filter(Boolean); + }); - function setTags(nextTags: string[]) { - value = nextTags.join(', '); + function setChips(next: string[]) { + if (listMode) { + values = next; + } else { + value = next.join(', '); + } } - function addCurrentTag() { + function addCurrentChip() { if (disabled) return; const next = inputValue.trim(); if (!next) return; - if (tags.some((existing) => existing.toLowerCase() === next.toLowerCase())) { + if (chips.some((existing) => existing.toLowerCase() === next.toLowerCase())) { inputValue = ''; return; } - if (typeof maxTagsCount === 'number' && maxTagsCount > 0 && tags.length >= maxTagsCount) { + if (typeof maxTagsCount === 'number' && maxTagsCount > 0 && chips.length >= maxTagsCount) { inputValue = ''; return; } - setTags([...tags, next]); + setChips([...chips, next]); inputValue = ''; } - function removeTag(tag: string) { + function removeChip(chip: string) { if (disabled) return; - setTags(tags.filter((entry) => entry !== tag)); + setChips(chips.filter((entry) => entry !== chip)); } function handleInput() { + // In list mode commas are literal characters (author names may contain + // them); only Enter/Tab/suggestion add a chip. + if (listMode) { + if (!fetchSuggestions) return; + clearTimeout(debounceTimer); + const trimmed = inputValue.trim(); + if (!trimmed) { + suggestions = []; + isOpen = false; + highlightedIndex = -1; + return; + } + isLoading = true; + debounceTimer = setTimeout(async () => { + try { + const results = await fetchSuggestions(trimmed); + suggestions = results; + isOpen = results.length > 0; + highlightedIndex = -1; + } catch { + suggestions = []; + isOpen = false; + } finally { + isLoading = false; + } + }, 250); + return; + } + const commaIdx = inputValue.lastIndexOf(','); if (commaIdx >= 0) { const before = inputValue.slice(0, commaIdx).trim(); - if (before && !tags.some((t) => t.toLowerCase() === before.toLowerCase())) { - if (!(typeof maxTagsCount === 'number' && maxTagsCount > 0 && tags.length >= maxTagsCount)) { - setTags([...tags, before]); + if (before && !chips.some((t) => t.toLowerCase() === before.toLowerCase())) { + if (!(typeof maxTagsCount === 'number' && maxTagsCount > 0 && chips.length >= maxTagsCount)) { + setChips([...chips, before]); } } inputValue = inputValue.slice(commaIdx + 1).trimStart(); @@ -100,18 +144,18 @@ }, 250); } - function selectSuggestion(tag: string) { + function selectSuggestion(chip: string) { if (disabled) return; - if (tags.some((existing) => existing.toLowerCase() === tag.toLowerCase())) { + if (chips.some((existing) => existing.toLowerCase() === chip.toLowerCase())) { inputValue = ''; suggestions = []; isOpen = false; return; } - if (typeof maxTagsCount === 'number' && maxTagsCount > 0 && tags.length >= maxTagsCount) { + if (typeof maxTagsCount === 'number' && maxTagsCount > 0 && chips.length >= maxTagsCount) { return; } - setTags([...tags, tag]); + setChips([...chips, chip]); inputValue = ''; suggestions = []; isOpen = false; @@ -143,21 +187,21 @@ } } - if (event.key === 'Enter' || event.key === 'Tab' || event.key === ',') { + if (event.key === 'Enter' || event.key === 'Tab' || (!listMode && event.key === ',')) { event.preventDefault(); - addCurrentTag(); + addCurrentChip(); return; } - if (event.key === 'Backspace' && inputValue === '' && tags.length > 0) { + if (event.key === 'Backspace' && inputValue === '' && chips.length > 0) { event.preventDefault(); - setTags(tags.slice(0, -1)); + setChips(chips.slice(0, -1)); } } function handleBlur() { if (!fetchSuggestions) { - addCurrentTag(); + addCurrentChip(); return; } setTimeout(() => { @@ -190,20 +234,20 @@
- {$_('book.tags')} + {label ?? $_('book.tags')}
- {#each tags as tag (tag)} + {#each chips as chip (chip)} - {tag} + {chip} {#if !disabled}
- {#if !fetchSuggestions} -

{$_('book.tagsHint')}

+ {#if hint !== undefined || !fetchSuggestions} +

{hint ?? $_('book.tagsHint')}

{/if}
diff --git a/frontend/src/lib/components/TagInput.test.ts b/frontend/src/lib/components/TagInput.test.ts index b70fbed2..1f7d6778 100644 --- a/frontend/src/lib/components/TagInput.test.ts +++ b/frontend/src/lib/components/TagInput.test.ts @@ -383,3 +383,63 @@ describe('TagInput', () => { expect(style).toContain('width:'); }); }); + +describe('TagInput (list mode)', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + cleanup(); + }); + + it('renders chips from values prop', () => { + render(TagInput, { props: { values: ['Frank Herbert', 'Neil Gaiman'] } }); + + expect(screen.getByText('Frank Herbert')).toBeInTheDocument(); + expect(screen.getByText('Neil Gaiman')).toBeInTheDocument(); + }); + + it('does not split on commas in list mode', async () => { + render(TagInput, { props: { values: [] } }); + + const input = screen.getByRole('textbox'); + await fireEvent.input(input, { target: { value: 'Asimov, Isaac' } }); + await fireEvent.keyDown(input, { key: 'Enter' }); + + expect(screen.getByText('Asimov, Isaac')).toBeInTheDocument(); + expect(screen.queryByText('Asimov')).not.toBeInTheDocument(); + }); + + it('emits the list via bindable values on Enter', async () => { + const { component } = render(TagInput, { props: { values: ['Frank Herbert'] } }); + + const input = screen.getByRole('textbox'); + await fireEvent.input(input, { target: { value: 'Brian Herbert' } }); + await fireEvent.keyDown(input, { key: 'Enter' }); + + expect(screen.getByText('Brian Herbert')).toBeInTheDocument(); + }); + + it('prevents duplicate chips case-insensitively', async () => { + render(TagInput, { props: { values: ['Frank Herbert'] } }); + + const input = screen.getByRole('textbox'); + await fireEvent.input(input, { target: { value: 'frank herbert' } }); + await fireEvent.keyDown(input, { key: 'Enter' }); + + expect(screen.getAllByText(/Frank Herbert/i)).toHaveLength(1); + expect(input).toHaveValue(''); + }); + + it('removes a chip via its remove button', async () => { + render(TagInput, { props: { values: ['Frank Herbert', 'Brian Herbert'] } }); + + const removeButtons = screen.getAllByLabelText('Remove'); + await fireEvent.click(removeButtons[0]); + + expect(screen.queryByText('Frank Herbert')).not.toBeInTheDocument(); + expect(screen.getByText('Brian Herbert')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 520c3bdd..f55bc157 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -59,8 +59,10 @@ "noRating": "Keine Bewertung", "topRated": "Am besten bewertet", "worstRated": "Am schlechtesten bewertet", - "showMore": "Mehr anzeigen" - }, + "showMore": "Mehr anzeigen", + "totalBooksAndAuthors": "Bücher insgesamt", + "booksFromAuthors": "von {authors} {authors, plural, one {Autor} other {Autoren}}" + }, "dashboard": { "title": "Lese-Dashboard", "subtitle": "Ein schneller Überblick über dein Lesen", @@ -76,13 +78,26 @@ "noSearchResults": "Keine Bücher gefunden", "noCurrentlyReading": "Du liest aktuell kein Buch.", "noNextToRead": "Noch keine Bücher in deiner Wunschliste.", - "popularTags": "Beliebte Tags" + "popularTags": "Beliebte Tags", + "gamificationTitle": "Streaks & Ziele", + "currentStreak": "Aktueller Streak", + "currentStreakHint": "Aufeinanderfolgende Tage mit Leseaktivität", + "longestStreak": "Längster Streak", + "longestStreakDateRange": "{start} – {end}", + "goalPagesPerDay": "Seiten pro Tag", + "goalPagesPerMonth": "Seiten pro Monat", + "goalBooksPerMonth": "Bücher pro Monat", + "goalBooksPerYear": "Bücher pro Jahr", + "goalReached": "Ziel erreicht", + "goalOf": "von {target}", + "goalProgress": "{current} von {target}" }, "status": { "want_to_read": "Möchte ich lesen", "currently_reading": "Lese ich gerade", "read": "Gelesen", - "did_not_finish": "Abgebrochen" + "did_not_finish": "Abgebrochen", + "all": "Alle Bücher" }, "common": { "all": "Alle", @@ -180,7 +195,10 @@ "progressPromptTitle": "Lesefortschritt setzen?", "progressPromptMessage": "Lesefortschritt für \"{title}\" auf 100% setzen?", "progressPromptSet": "Auf 100% setzen", - "progressPromptSkip": "Überspringen" + "progressPromptSkip": "Überspringen", + "authorsPlaceholder": "Autor eingeben und Enter drücken", + "authorsHint": "Enter fügt einen Autor hinzu. Mit Rücktaste entfernt man den letzten.", + "authorsLabel": "Autor(en)" }, "addModal": { "manual": "Manuell", @@ -291,7 +309,15 @@ "resultsCount": "{count, plural, one {Ergebnis} other {Ergebnisse}} gefunden", "noResults": "Keine Ergebnisse gefunden", "noResultsFor": "Keine Ergebnisse für \"{query}\" gefunden", - "tryDifferentQuery": "Versuche einen anderen Suchbegriff" + "tryDifferentQuery": "Versuche einen anderen Suchbegriff", + "help": { + "title": "Suchsyntax", + "intro": "Verwende Feld-Präfixe, um in einem bestimmten Feld zu suchen. Präfixe sind immer auf Englisch.", + "multiWord": "Setze mehrteilige Werte in doppelte Anführungszeichen, z. B. author:\"Marlen Haushofer\".", + "combine": "Du kannst mehrere Präfixe kombinieren; die Ergebnisse müssen allen entsprechen.", + "negate": "Stelle einem Begriff ein - voran, um ihn auszuschließen, z. B. tag:cars -tag:audi.", + "possessionValues": "Possession-Werte: owned, borrowed, digital_access, to_acquire" + } }, "languages": { "en": "Englisch", @@ -440,7 +466,8 @@ "exportNoDatasets": "Wähle mindestens einen Datensatz zum Exportieren aus.", "importMappingNameConflict": "Ein Mapping mit diesem Namen existiert bereits.", "importMappingNotFound": "Import-Mapping nicht gefunden.", - "importFileNotFound": "Importdatei nicht gefunden. Bitte lade die Datei erneut hoch." + "importFileNotFound": "Importdatei nicht gefunden. Bitte lade die Datei erneut hoch.", + "importInvalidDelimiter": "CSV-Trennzeichen muss ein einzelnes Zeichen sein." }, "oidc": { "orContinueWith": "oder weiter mit", @@ -463,6 +490,22 @@ "profileSaveFailed": "Profil konnte nicht gespeichert werden", "passwordChangeSuccess": "Passwort geändert", "passwordChangeFailed": "Passwort konnte nicht geändert werden", + "goals": { + "title": "Leseziele", + "subtitle": "Aktiviere Ziele, um sie spielerisch auf dem Dashboard zu verfolgen.", + "dashboardToggle": "Lese-Serien & Ziele auf dem Dashboard anzeigen", + "pagesPerDay": "Seiten pro Tag", + "pagesPerMonth": "Seiten pro Monat", + "booksPerMonth": "Bücher pro Monat", + "booksPerYear": "Bücher pro Jahr", + "target": "Ziel", + "pagesPerDayTarget": "Ziel: Seiten pro Tag", + "pagesPerMonthTarget": "Ziel: Seiten pro Monat", + "booksPerMonthTarget": "Ziel: Bücher pro Monat", + "booksPerYearTarget": "Ziel: Bücher pro Jahr", + "saveSuccess": "Leseziele gespeichert", + "invalidTarget": "{name} muss eine ganze Zahl von mindestens 1 sein." + }, "dataManagement": { "title": "Meine Daten verwalten", "description": "Exportiere deine Bibliothek oder importiere Bücher aus CSV/JSON.", @@ -606,11 +649,13 @@ "validateFailed": "Validierung fehlgeschlagen.", "previewFailed": "Vorschau konnte nicht geladen werden.", "executeFailed": "Import fehlgeschlagen." - } + }, + "authorsHint": "Ein einzelner Autor (getrennt mit ;, & oder und) oder ein Array mit Namen — jeder Eintrag wird ein Autor.", + "delimiterLabel": "CSV-Trennzeichen" } }, - "dataHygiene": { - "authorRequired": "Autor darf nicht leer sein.", + "dataHygiene": { + "authorRequired": "Autor darf nicht leer sein.", "pageCountPositive": "Seitenzahl muss größer als 0 sein.", "title": "Datenpflege", "description": "Finde und korrigiere Bücher mit fehlenden Metadaten in deiner Bibliothek.", @@ -702,8 +747,8 @@ "allDoneSub": "Jedes Buch in deiner Bibliothek hat jetzt ein Cover.", "loadingBook": "Nächstes Buch wird geladen...", "loadingCandidates": "Cover-Quellen werden durchsucht...", - "keyboardHint": "Tipp: Drücke 1\u20139, um ein Cover auszuwählen, \u2192 zum Überspringen", + "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" } -} +} \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 163ce136..eabd2185 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -23,6 +23,8 @@ "busiestMonth": "Busiest Month", "avgPageCount": "Avg Pages/Book", "mostPopularLanguage": "Most Popular Language", + "totalBooksAndAuthors": "Total Books", + "booksFromAuthors": "from {authors} {authors, plural, one {author} other {authors}}", "languageDistribution": "Books by Language", "statusDistribution": "Books by Status", "acquisitionStatusDistribution": "Books by Ownership", @@ -76,13 +78,26 @@ "noSearchResults": "No books found", "noCurrentlyReading": "You are not currently reading a book.", "noNextToRead": "No books in your want-to-read list yet.", - "popularTags": "Popular Tags" + "popularTags": "Popular Tags", + "gamificationTitle": "Reading Streaks & Goals", + "currentStreak": "Current Streak", + "currentStreakHint": "Consecutive days with reading activity", + "longestStreak": "Longest Streak", + "longestStreakDateRange": "{start} – {end}", + "goalPagesPerDay": "Pages per Day", + "goalPagesPerMonth": "Pages per Month", + "goalBooksPerMonth": "Books per Month", + "goalBooksPerYear": "Books per Year", + "goalReached": "Goal reached", + "goalOf": "of {target}", + "goalProgress": "{current} of {target}" }, "status": { "want_to_read": "Want to Read", "currently_reading": "Currently Reading", "read": "Read", - "did_not_finish": "Did Not Finish" + "did_not_finish": "Did Not Finish", + "all": "All Books" }, "common": { "all": "All", @@ -145,7 +160,7 @@ "blurb": "Description", "about": "About", "dateStarted": "Date started", - "acquisitionStatus": "Availability", + "acquisitionStatus": "Possession", "selectAcquisitionStatus": "Select availability...", "startDatePromptTitle": "Set a start date?", "startDatePromptMessage": "This book has no start date. Set it to today or choose another date before marking it as read.", @@ -180,7 +195,10 @@ "progressPromptTitle": "Set Reading Progress?", "progressPromptMessage": "Set the reading progress for \"{title}\" to 100%?", "progressPromptSet": "Set to 100%", - "progressPromptSkip": "Skip" + "progressPromptSkip": "Skip", + "authorsPlaceholder": "Type an author and press Enter", + "authorsLabel": "Author(s)", + "authorsHint": "Press Enter to add authors. Backspace removes the last author." }, "addModal": { "manual": "Manual", @@ -291,7 +309,15 @@ "resultsCount": "{count, plural, one {result} other {results}} found", "noResults": "No results found", "noResultsFor": "No results found for \"{query}\"", - "tryDifferentQuery": "Try a different search term" + "tryDifferentQuery": "Try a different search term", + "help": { + "title": "Search syntax", + "intro": "Use field prefixes to search in a specific field. Prefixes are always English.", + "multiWord": "Wrap multi-word values in double quotes, e.g. author:\"Marlen Haushofer\".", + "combine": "You can combine multiple prefixes; results must match all of them.", + "negate": "Prefix any term with - to exclude it, e.g. tag:cars -tag:audi.", + "possessionValues": "possession values: owned, borrowed, digital_access, to_acquire" + } }, "languages": { "en": "English", @@ -440,7 +466,8 @@ "tooManyBooksSelected": "Too many books selected. Please select at most {max} at a time.", "importMappingNameConflict": "A mapping with this name already exists.", "importMappingNotFound": "Import mapping not found.", - "importFileNotFound": "Import file not found. Please upload the file again." + "importFileNotFound": "Import file not found. Please upload the file again.", + "importInvalidDelimiter": "CSV delimiter must be a single character." }, "oidc": { "orContinueWith": "or continue with", @@ -463,6 +490,22 @@ "profileSaveFailed": "Failed to save profile", "passwordChangeSuccess": "Password changed", "passwordChangeFailed": "Failed to change password", + "goals": { + "title": "Reading Goals", + "subtitle": "Enable goals to track them playfully on your dashboard.", + "dashboardToggle": "Show reading streaks & goals on dashboard", + "pagesPerDay": "Pages per Day", + "pagesPerMonth": "Pages per Month", + "booksPerMonth": "Books per Month", + "booksPerYear": "Books per Year", + "target": "Target", + "pagesPerDayTarget": "Pages per Day target", + "pagesPerMonthTarget": "Pages per Month target", + "booksPerMonthTarget": "Books per Month target", + "booksPerYearTarget": "Books per Year target", + "saveSuccess": "Reading goals saved", + "invalidTarget": "{name} must be a whole number of at least 1." + }, "dataManagement": { "title": "Manage my data", "description": "Export your library or import books from a CSV/JSON file.", @@ -532,6 +575,7 @@ "import": { "title": "Import", "description": "Upload one CSV or JSON file, map fields, validate, then import.", + "delimiterLabel": "CSV delimiter", "parse": "Parse file", "parsing": "Parsing...", "fileSummary": "Rows: {rows}, fields: {fields}", @@ -565,6 +609,7 @@ "requiredField": "= required field", "changeFile": "Change file", "coverUrlHint": "Expects an HTTP(S) URL to an image. Local file paths and base64 data are not supported.", + "authorsHint": "Map a single author string (separated with ;, & or and) or an array of names — each entry becomes one author.", "transformHelp": "Available parameters and examples", "transformHelpValue": "The raw value of the mapped source field", "transformHelpRow": "All source fields as a dict, e.g. row['title']", @@ -609,10 +654,10 @@ } } }, - "dataHygiene": { - "authorRequired": "Author cannot be empty.", - "pageCountPositive": "Page count must be greater than 0.", - "title": "Data Hygiene", + "dataHygiene": { + "authorRequired": "Author cannot be empty.", + "pageCountPositive": "Page count must be greater than 0.", + "title": "Data Hygiene", "description": "Find and fix books with missing metadata in your library.", "attributes": { "author": "Author", @@ -702,8 +747,8 @@ "allDoneSub": "Every book in your library now has a cover image.", "loadingBook": "Loading next book...", "loadingCandidates": "Searching cover sources...", - "keyboardHint": "Tip: Press 1\u20139 to select a cover, \u2192 to skip", + "keyboardHint": "Tip: Press 1–9 to select a cover, → to skip", "candidatesError": "Cover search failed. You can still use manual import.", "retry": "Retry" } -} +} \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index 9cd2edcc..23a14f13 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -59,7 +59,9 @@ "noRating": "Sin valoración", "topRated": "Mejor valorados", "worstRated": "Peor valorados", - "showMore": "Mostrar más" + "showMore": "Mostrar más", + "totalBooksAndAuthors": "Libros en total", + "booksFromAuthors": "de {authors} {authors, plural, one {autor} other {autores}}" }, "dashboard": { "title": "Panel de lectura", @@ -76,13 +78,26 @@ "noSearchResults": "No se encontraron libros", "noCurrentlyReading": "No estás leyendo ningún libro actualmente.", "noNextToRead": "Aún no hay libros en tu lista de deseos.", - "popularTags": "Etiquetas populares" + "popularTags": "Etiquetas populares", + "gamificationTitle": "Rachas de lectura y metas", + "currentStreak": "Racha actual", + "currentStreakHint": "Días consecutivos con actividad de lectura", + "longestStreak": "Racha más larga", + "longestStreakDateRange": "{start} – {end}", + "goalPagesPerDay": "Páginas por día", + "goalPagesPerMonth": "Páginas por mes", + "goalBooksPerMonth": "Libros por mes", + "goalBooksPerYear": "Libros por año", + "goalReached": "Meta alcanzada", + "goalOf": "de {target}", + "goalProgress": "{current} de {target}" }, "status": { "want_to_read": "Quiero leer", "currently_reading": "Leyendo", "read": "Leído", - "did_not_finish": "Abandonado" + "did_not_finish": "Abandonado", + "all": "Todos los libros" }, "common": { "all": "Todos", @@ -180,7 +195,10 @@ "progressPromptTitle": "¿Establecer progreso de lectura?", "progressPromptMessage": "¿Establecer el progreso de \"{title}\" al 100%?", "progressPromptSet": "Establecer al 100%", - "progressPromptSkip": "Saltar" + "progressPromptSkip": "Saltar", + "authorsPlaceholder": "Escribe un autor y pulsa Enter", + "authorsHint": "Pulsa Enter para añadir autores. Retroceso elimina el último.", + "authorsLabel": "Autor(es)" }, "addModal": { "manual": "Manual", @@ -291,7 +309,15 @@ "resultsCount": "{count, plural, one {resultado} other {resultados}} encontrados", "noResults": "No se encontraron resultados", "noResultsFor": "No se encontraron resultados para \"{query}\"", - "tryDifferentQuery": "Prueba con un término de búsqueda diferente" + "tryDifferentQuery": "Prueba con un término de búsqueda diferente", + "help": { + "title": "Sintaxis de búsqueda", + "intro": "Usa prefijos de campo para buscar en un campo concreto. Los prefijos siempre están en inglés.", + "multiWord": "Envuelve los valores de varias palabras entre comillas dobles, p. ej. author:\"Marlen Haushofer\".", + "combine": "Puedes combinar varios prefijos; los resultados deben coincidir con todos.", + "negate": "Antepón - a cualquier término para excluirlo, p. ej. tag:cars -tag:audi.", + "possessionValues": "valores de posesión: owned, borrowed, digital_access, to_acquire" + } }, "languages": { "en": "Inglés", @@ -440,7 +466,8 @@ "tooManyBooksSelected": "Demasiados libros seleccionados. Selecciona como máximo {max} a la vez.", "importMappingNameConflict": "Ya existe una asignación con este nombre.", "importMappingNotFound": "Asignación de importación no encontrada.", - "importFileNotFound": "Archivo de importación no encontrado. Vuelve a subir el archivo." + "importFileNotFound": "Archivo de importación no encontrado. Vuelve a subir el archivo.", + "importInvalidDelimiter": "El delimitador CSV debe ser un solo carácter." }, "oidc": { "orContinueWith": "o continuar con", @@ -463,6 +490,22 @@ "profileSaveFailed": "Error al guardar el perfil", "passwordChangeSuccess": "Contraseña cambiada", "passwordChangeFailed": "Error al cambiar la contraseña", + "goals": { + "title": "Metas de lectura", + "subtitle": "Activa metas para seguirlas de forma lúdica en el panel.", + "dashboardToggle": "Mostrar rachas y metas de lectura en el panel", + "pagesPerDay": "Páginas por día", + "pagesPerMonth": "Páginas por mes", + "booksPerMonth": "Libros por mes", + "booksPerYear": "Libros por año", + "target": "Meta", + "pagesPerDayTarget": "Meta de páginas por día", + "pagesPerMonthTarget": "Meta de páginas por mes", + "booksPerMonthTarget": "Meta de libros por mes", + "booksPerYearTarget": "Meta de libros por año", + "saveSuccess": "Metas de lectura guardadas", + "invalidTarget": "{name} debe ser un número entero de al menos 1." + }, "dataManagement": { "title": "Gestionar mis datos", "description": "Exporta tu biblioteca o importa libros desde un archivo CSV/JSON.", @@ -606,7 +649,9 @@ "validateFailed": "Validación fallida.", "previewFailed": "Error al cargar la vista previa.", "executeFailed": "Importación fallida." - } + }, + "authorsHint": "Asigna una sola cadena de autor (separada por ;, & o y) o un array de nombres: cada entrada se convierte en un autor.", + "delimiterLabel": "Delimitador CSV" } }, "dataHygiene": { @@ -702,8 +747,8 @@ "allDoneSub": "Todos los libros de tu biblioteca tienen ahora una imagen de portada.", "loadingBook": "Cargando siguiente libro...", "loadingCandidates": "Buscando fuentes de portadas...", - "keyboardHint": "Consejo: pulsa 1\u20139 para seleccionar una portada, \u2192 para saltar", + "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" } -} +} \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json index f66d0537..9d27572c 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -59,7 +59,9 @@ "noRating": "Aucune note", "topRated": "Les mieux notés", "worstRated": "Les moins bien notés", - "showMore": "Afficher plus" + "showMore": "Afficher plus", + "totalBooksAndAuthors": "Livres au total", + "booksFromAuthors": "de {authors} {authors, plural, one {auteur} other {auteurs}}" }, "dashboard": { "title": "Tableau de bord de lecture", @@ -76,13 +78,26 @@ "noSearchResults": "Aucun livre trouvé", "noCurrentlyReading": "Tu ne lis aucun livre actuellement.", "noNextToRead": "Aucun livre dans ta liste d'envies.", - "popularTags": "Étiquettes populaires" + "popularTags": "Étiquettes populaires", + "gamificationTitle": "Séries de lecture & objectifs", + "currentStreak": "Série actuelle", + "currentStreakHint": "Jours consécutifs avec activité de lecture", + "longestStreak": "Série la plus longue", + "longestStreakDateRange": "{start} – {end}", + "goalPagesPerDay": "Pages par jour", + "goalPagesPerMonth": "Pages par mois", + "goalBooksPerMonth": "Livres par mois", + "goalBooksPerYear": "Livres par an", + "goalReached": "Objectif atteint", + "goalOf": "sur {target}", + "goalProgress": "{current} sur {target}" }, "status": { "want_to_read": "À lire", "currently_reading": "En cours", "read": "Lu", - "did_not_finish": "Abandonné" + "did_not_finish": "Abandonné", + "all": "Tous les livres" }, "common": { "all": "Tous", @@ -180,7 +195,10 @@ "progressPromptTitle": "Définir la progression ?", "progressPromptMessage": "Définir la progression de \"{title}\" à 100 % ?", "progressPromptSet": "Définir à 100 %", - "progressPromptSkip": "Passer" + "progressPromptSkip": "Passer", + "authorsPlaceholder": "Saisis un auteur et appuie sur Entrée", + "authorsHint": "Entrée pour ajouter des auteurs. Retour arrière supprime le dernier.", + "authorsLabel": "Auteur(s)" }, "addModal": { "manual": "Manuel", @@ -291,7 +309,15 @@ "resultsCount": "{count, plural, one {résultat} other {résultats}} trouvés", "noResults": "Aucun résultat trouvé", "noResultsFor": "Aucun résultat trouvé pour \"{query}\"", - "tryDifferentQuery": "Essaie un autre terme de recherche" + "tryDifferentQuery": "Essaie un autre terme de recherche", + "help": { + "title": "Syntaxe de recherche", + "intro": "Utilise des préfixes de champ pour rechercher dans un champ précis. Les préfixes sont toujours en anglais.", + "multiWord": "Place les valeurs multi-mots entre guillemets doubles, p. ex. author:\"Marlen Haushofer\".", + "combine": "Tu peux combiner plusieurs préfixes ; les résultats doivent correspondre à tous.", + "negate": "Préfixe tout terme par - pour l'exclure, p. ex. tag:cars -tag:audi.", + "possessionValues": "valeurs de possession : owned, borrowed, digital_access, to_acquire" + } }, "languages": { "en": "Anglais", @@ -440,7 +466,8 @@ "tooManyBooksSelected": "Trop de livres sélectionnés. Sélectionne au maximum {max} à la fois.", "importMappingNameConflict": "Un mappage avec ce nom existe déjà.", "importMappingNotFound": "Mappage d'importation introuvable.", - "importFileNotFound": "Fichier d'importation introuvable. Veuillez téléverser le fichier à nouveau." + "importFileNotFound": "Fichier d'importation introuvable. Veuillez téléverser le fichier à nouveau.", + "importInvalidDelimiter": "Le séparateur CSV doit être un seul caractère." }, "oidc": { "orContinueWith": "ou continuer avec", @@ -463,6 +490,22 @@ "profileSaveFailed": "Échec de l'enregistrement du profil", "passwordChangeSuccess": "Mot de passe modifié", "passwordChangeFailed": "Échec de la modification du mot de passe", + "goals": { + "title": "Objectifs de lecture", + "subtitle": "Activez des objectifs pour les suivre de façon ludique sur votre tableau de bord.", + "dashboardToggle": "Afficher les séries et objectifs de lecture sur le tableau de bord", + "pagesPerDay": "Pages par jour", + "pagesPerMonth": "Pages par mois", + "booksPerMonth": "Livres par mois", + "booksPerYear": "Livres par an", + "target": "Objectif", + "pagesPerDayTarget": "Objectif de pages par jour", + "pagesPerMonthTarget": "Objectif de pages par mois", + "booksPerMonthTarget": "Objectif de livres par mois", + "booksPerYearTarget": "Objectif de livres par an", + "saveSuccess": "Objectifs de lecture enregistrés", + "invalidTarget": "{name} doit être un nombre entier d'au moins 1." + }, "dataManagement": { "title": "Gérer mes données", "description": "Exporte ta bibliothèque ou importe des livres depuis un fichier CSV/JSON.", @@ -606,7 +649,9 @@ "validateFailed": "Échec de la validation.", "previewFailed": "Échec du chargement de l'aperçu.", "executeFailed": "Échec de l'importation." - } + }, + "authorsHint": "Mappez une chaîne d'auteur unique (séparée par ;, & ou et) ou un tableau de noms — chaque entrée devient un auteur.", + "delimiterLabel": "Séparateur CSV" } }, "dataHygiene": { @@ -702,8 +747,8 @@ "allDoneSub": "Chaque livre de ta bibliothèque a maintenant une image de couverture.", "loadingBook": "Chargement du livre suivant...", "loadingCandidates": "Recherche de sources de couvertures...", - "keyboardHint": "Astuce : appuie sur 1\u20139 pour sélectionner une couverture, \u2192 pour passer", + "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" } -} +} \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/zh.json b/frontend/src/lib/i18n/locales/zh.json index c9c42e21..d9f7ee4f 100644 --- a/frontend/src/lib/i18n/locales/zh.json +++ b/frontend/src/lib/i18n/locales/zh.json @@ -59,7 +59,9 @@ "noRating": "暂无评分", "topRated": "评分最高", "worstRated": "评分最低", - "showMore": "显示更多" + "showMore": "显示更多", + "totalBooksAndAuthors": "书籍总数", + "booksFromAuthors": "来自 {authors} 位不同的作者" }, "dashboard": { "title": "阅读仪表盘", @@ -76,13 +78,26 @@ "noSearchResults": "未找到图书", "noCurrentlyReading": "你当前没有在读的图书。", "noNextToRead": "你的想读列表还没有图书。", - "popularTags": "热门标签" + "popularTags": "热门标签", + "gamificationTitle": "阅读连续天数与目标", + "currentStreak": "当前连续", + "currentStreakHint": "有阅读活动的连续天数", + "longestStreak": "最长连续", + "longestStreakDateRange": "{start} – {end}", + "goalPagesPerDay": "每天页数", + "goalPagesPerMonth": "每月页数", + "goalBooksPerMonth": "每月本书", + "goalBooksPerYear": "每年本书", + "goalReached": "目标达成", + "goalOf": "共 {target}", + "goalProgress": "{current} / {target}" }, "status": { "want_to_read": "想读", "currently_reading": "在读", "read": "已读", - "did_not_finish": "弃读" + "did_not_finish": "弃读", + "all": "所有书籍" }, "common": { "all": "全部", @@ -180,7 +195,10 @@ "progressPromptTitle": "设置阅读进度?", "progressPromptMessage": "将 \"{title}\" 的阅读进度设置为 100%?", "progressPromptSet": "设为 100%", - "progressPromptSkip": "跳过" + "progressPromptSkip": "跳过", + "authorsPlaceholder": "输入作者并按回车", + "authorsHint": "按回车添加作者。按退格键删除上一个。", + "authorsLabel": "作者" }, "addModal": { "manual": "手动添加", @@ -291,7 +309,15 @@ "resultsCount": "找到 {count} 个结果", "noResults": "未找到结果", "noResultsFor": "未找到 \"{query}\" 的结果", - "tryDifferentQuery": "尝试其他搜索词" + "tryDifferentQuery": "尝试其他搜索词", + "help": { + "title": "搜索语法", + "intro": "使用字段前缀在特定字段中搜索。前缀始终为英文。", + "multiWord": "多词值请用双引号括起来,例如 author:\"Marlen Haushofer\"。", + "combine": "可以组合多个前缀;结果必须满足所有条件。", + "negate": "在任何词条前加 - 以将其排除,例如 tag:cars -tag:audi。", + "possessionValues": "持有值:owned、borrowed、digital_access、to_acquire" + } }, "languages": { "en": "英语", @@ -440,7 +466,8 @@ "tooManyBooksSelected": "选择的图书过多。一次最多选择 {max} 本。", "importMappingNameConflict": "同名映射已存在。", "importMappingNotFound": "未找到导入映射。", - "importFileNotFound": "未找到导入文件。请重新上传文件。" + "importFileNotFound": "未找到导入文件。请重新上传文件。", + "importInvalidDelimiter": "CSV 分隔符必须是单个字符。" }, "oidc": { "orContinueWith": "或继续使用", @@ -463,6 +490,22 @@ "profileSaveFailed": "保存个人资料失败", "passwordChangeSuccess": "密码已更改", "passwordChangeFailed": "更改密码失败", + "goals": { + "title": "阅读目标", + "subtitle": "启用目标,即可在仪表板上趣味追踪进度。", + "dashboardToggle": "在仪表板上显示阅读连续天数与目标", + "pagesPerDay": "每天页数", + "pagesPerMonth": "每月页数", + "booksPerMonth": "每月本书", + "booksPerYear": "每年本书", + "target": "目标", + "pagesPerDayTarget": "每天页数目标", + "pagesPerMonthTarget": "每月页数目标", + "booksPerMonthTarget": "每月本书目标", + "booksPerYearTarget": "每年本书目标", + "saveSuccess": "阅读目标已保存", + "invalidTarget": "{name} 必须是至少为 1 的整数。" + }, "dataManagement": { "title": "管理我的数据", "description": "导出你的书库或从 CSV/JSON 文件导入图书。", @@ -606,7 +649,9 @@ "validateFailed": "验证失败。", "previewFailed": "加载预览失败。", "executeFailed": "导入失败。" - } + }, + "authorsHint": "映射单个作者字符串(用 ;、& 或 和 分隔)或名称数组 — 每个条目成为一个作者。", + "delimiterLabel": "CSV 分隔符" } }, "dataHygiene": { @@ -702,8 +747,8 @@ "allDoneSub": "你书库中的每本书现在都有封面。", "loadingBook": "正在加载下一本书...", "loadingCandidates": "正在搜索封面来源...", - "keyboardHint": "提示:按 1\u20139 选择封面,按 \u2192 跳过", + "keyboardHint": "提示:按 1–9 选择封面,按 → 跳过", "candidatesError": "封面搜索失败。你仍可使用手动导入。", "retry": "重试" } -} +} \ No newline at end of file diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index fba0e4c0..249d3705 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -6,6 +6,7 @@ export interface Book { title: string; subtitle: string | null; author: string | null; + authors: string[]; isbn: string | null; cover_url: string | null; publisher: string | null; @@ -32,6 +33,7 @@ export interface BookImportCandidate { title: string; subtitle: string | null; author: string | null; + authors: string[] | null; isbn: string | null; cover_url: string | null; publisher: string | null; @@ -158,12 +160,15 @@ export interface TopRatedBook { book_id: number; title: string; author: string | null; + authors: string[]; rating: number; reading_status: ReadingStatus; cover_url: string | null; } export interface StatisticsResponse { + total_books: number; + total_authors: number; avg_books_per_month: number | null; busiest_month: string | null; busiest_month_count: number | null; @@ -214,6 +219,33 @@ export interface UserSettings { timezone: string; theme: string; custom_theme: string | null; + goal_pages_per_day_enabled: boolean; + goal_pages_per_day: number; + goal_pages_per_month_enabled: boolean; + goal_pages_per_month: number; + goal_books_per_month_enabled: boolean; + goal_books_per_month: number; + goal_books_per_year_enabled: boolean; + goal_books_per_year: number; + gamification_enabled: boolean; +} + +export type GoalType = 'pages_per_day' | 'pages_per_month' | 'books_per_month' | 'books_per_year'; + +export interface GoalProgress { + type: GoalType; + target: number; + current: number; + reached: boolean; +} + +export interface GamificationResponse { + enabled: boolean; + current_streak: number; + longest_streak: number; + longest_streak_start: string | null; + longest_streak_end: string | null; + goals: GoalProgress[]; } export interface ApiKeyMeta { @@ -245,6 +277,7 @@ export interface DataResetResponse { deleted: { books: number; tags: number; + authors: number; progress_entries: number; }; } @@ -344,8 +377,8 @@ export interface ImportFieldConfig { export interface DataImportPreviewRow { row_number: number; - source: Record; - transformed: Record; + source: Record; + transformed: Record; errors: string[]; } @@ -377,6 +410,7 @@ export interface HygieneMissingBook { id: number; title: string; author: string | null; + authors: string[]; isbn: string | null; publisher: string | null; published_year: number | null; diff --git a/frontend/src/lib/utils/authors.test.ts b/frontend/src/lib/utils/authors.test.ts new file mode 100644 index 00000000..81967863 --- /dev/null +++ b/frontend/src/lib/utils/authors.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { formatAuthors } from './authors'; + +describe('formatAuthors', () => { + it('joins multiple authors with "; "', () => { + expect(formatAuthors(['Terry Pratchett', 'Neil Gaiman'])).toBe('Terry Pratchett; Neil Gaiman'); + }); + + it('returns the single author as-is', () => { + expect(formatAuthors(['Frank Herbert'])).toBe('Frank Herbert'); + }); + + it('returns the fallback for empty/null/undefined', () => { + expect(formatAuthors([])).toBe('—'); + expect(formatAuthors(null)).toBe('—'); + expect(formatAuthors(undefined)).toBe('—'); + }); + + it('supports a custom fallback', () => { + expect(formatAuthors([], 'Unknown')).toBe('Unknown'); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/utils/authors.ts b/frontend/src/lib/utils/authors.ts new file mode 100644 index 00000000..b9f30536 --- /dev/null +++ b/frontend/src/lib/utils/authors.ts @@ -0,0 +1,7 @@ +export function formatAuthors( + authors: string[] | null | undefined, + fallback: string | null | undefined = '—' +): string { + if (!authors || authors.length === 0) return fallback || '—'; + return authors.join('; '); +} \ No newline at end of file diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index 14cc8b14..c7ca372b 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -2,18 +2,23 @@ import { page } from '$app/stores'; import { goto } from '$app/navigation'; import { onMount } from 'svelte'; - import type { Book, DashboardQuote, LibraryStats } from '$lib/types'; + import type { Book, DashboardQuote, GamificationResponse, LibraryStats } from '$lib/types'; import { api } from '$lib/api'; import { _ } from '$lib/i18n'; import { toasts } from '$lib/toasts'; import { shouldShowActionToast } from '$lib/errors'; import { isQuoteServiceEnabled } from '$lib/stores/timezone'; + import { formatAuthors } from '$lib/utils/authors'; import BookCard from '$lib/components/BookCard.svelte'; import BookDetailDialog from '$lib/components/BookDetailDialog.svelte'; import BookDrawer from '$lib/components/BookDrawer.svelte'; + import GamificationCard from '$lib/components/GamificationCard.svelte'; + import SearchHelp from '$lib/components/SearchHelp.svelte'; import { Search, X } from '@lucide/svelte'; let loading = $state(true); + let gamification = $state(null); + let gamificationLoading = $state(true); let stats = $state({ total_books: 0, books_read: 0, @@ -98,7 +103,7 @@ import { Search, X } from '@lucide/svelte'; stats = statsData; currentlyReading = readingResponse.books.slice(0, 5); - nextToRead = wantToReadResponse.books.slice(0, 5); + nextToRead = shuffle(wantToReadResponse.books).slice(0, 5); const allBooks = [...currentlyReading, ...nextToRead]; void loadProgressForBooks(allBooks); @@ -111,10 +116,35 @@ import { Search, X } from '@lucide/svelte'; loading = false; } + await loadGamification(true); await loadQuote(); await loadTagCloud(); } + function shuffle(items: T[]): T[] { + const copy = [...items]; + for (let i = copy.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [copy[i], copy[j]] = [copy[j], copy[i]]; + } + return copy; + } + + async function loadGamification(showLoading = false) { + // Gamification is optional: a failure must not blank the dashboard. + // Once the section is disabled it stays hidden until the dashboard + // is (re)loaded, so repeated progress-change callbacks are no-ops. + if (gamification && gamification.enabled === false) return; + if (showLoading) gamificationLoading = true; + try { + gamification = await api.statistics.gamification(); + } catch { + gamification = { enabled: false, current_streak: 0, longest_streak: 0, longest_streak_start: null, longest_streak_end: null, goals: [] }; + } finally { + if (showLoading) gamificationLoading = false; + } + } + async function loadTagCloud() { try { tagCloud = await api.books.tagCloud(50); @@ -179,6 +209,7 @@ import { Search, X } from '@lucide/svelte'; function handleProgressChange(bookId: number, currentPage: number) { progressMap = { ...progressMap, [bookId]: currentPage }; + void loadGamification(); } function handleDelete(id: number) { @@ -300,7 +331,7 @@ import { Search, X } from '@lucide/svelte';
-
+

{$_('dashboard.title')}

{$_('dashboard.subtitle')}

@@ -311,8 +342,9 @@ import { Search, X } from '@lucide/svelte';

{$_('dashboard.searchAllBooks')}

-
- +
+
+

{book.title}

{#if book.author} -

{book.author}

+

{formatAuthors(book.authors, book.author)}

{/if} {$_(STATUS_LABEL_KEYS[book.reading_status])} @@ -389,6 +421,8 @@ import { Search, X } from '@lucide/svelte'; {/if}
{/if} +
+
@@ -451,6 +485,17 @@ import { Search, X } from '@lucide/svelte';
+ {#if gamification === null || gamification.enabled} + + {/if} +
diff --git a/frontend/src/routes/data-hygiene/+page.svelte b/frontend/src/routes/data-hygiene/+page.svelte index 82494e00..c2a0529d 100644 --- a/frontend/src/routes/data-hygiene/+page.svelte +++ b/frontend/src/routes/data-hygiene/+page.svelte @@ -9,6 +9,7 @@ import BookDrawer from '$lib/components/BookDrawer.svelte'; import SuggestionInput from '$lib/components/SuggestionInput.svelte'; import { LoaderCircle, X } from '@lucide/svelte'; + import { formatAuthors } from '$lib/utils/authors'; import type { Book, HygieneAttribute, HygieneMissingBook } from '$lib/types'; const ATTRIBUTES: { key: HygieneAttribute; labelKey: string }[] = [ @@ -329,7 +330,7 @@ /> {$_('book.title')} - {$_('book.author')} + {$_('book.authorsLabel')} {$_('book.isbn')} {$_('book.publisher')} {$_('dataHygiene.tableHeaderMissing')} @@ -351,7 +352,7 @@
{book.title} - {book.author || '—'} + {formatAuthors(book.authors, book.author || '—')} {book.isbn || '—'} {book.publisher || '—'} diff --git a/frontend/src/routes/data-hygiene/page.test.ts b/frontend/src/routes/data-hygiene/page.test.ts index 17062dc3..5d62533b 100644 --- a/frontend/src/routes/data-hygiene/page.test.ts +++ b/frontend/src/routes/data-hygiene/page.test.ts @@ -31,6 +31,7 @@ function mockBook(id: number, overrides?: Partial): HygieneM id, title: `Book ${id}`, author: id % 2 === 0 ? `Author ${id}` : null, + authors: id % 2 === 0 ? [`Author ${id}`] : [], isbn: id % 3 === 0 ? `978${String(id).padStart(10, '0')}` : null, publisher: id % 2 === 0 ? 'Publisher' : null, published_year: null, diff --git a/frontend/src/routes/library/+page.svelte b/frontend/src/routes/library/+page.svelte index f664d2c4..a3e3fd68 100644 --- a/frontend/src/routes/library/+page.svelte +++ b/frontend/src/routes/library/+page.svelte @@ -13,10 +13,11 @@ import BookDrawer from '$lib/components/BookDrawer.svelte'; import AddBookModal from '$lib/components/AddBookModal.svelte'; import SearchBar from '$lib/components/SearchBar.svelte'; - import { BookOpen as BookOpenIcon, Book as BookIcon, Check, X } from '@lucide/svelte'; + import SearchHelp from '$lib/components/SearchHelp.svelte'; + import { BookOpen as BookOpenIcon, Book as BookIcon, Check, Library, X } from '@lucide/svelte'; type Tab = { - status: ReadingStatus; + status: ReadingStatus | 'all'; labelKey: string; Icon: typeof BookOpenIcon; }; @@ -25,20 +26,22 @@ { status: 'want_to_read', labelKey: 'status.want_to_read', Icon: BookOpenIcon }, { status: 'currently_reading', labelKey: 'status.currently_reading', Icon: BookIcon }, { status: 'read', labelKey: 'status.read', Icon: Check }, - { status: 'did_not_finish', labelKey: 'status.did_not_finish', Icon: X } + { status: 'did_not_finish', labelKey: 'status.did_not_finish', Icon: X }, + { status: 'all', labelKey: 'status.all', Icon: Library } ]; const STATUS_LABEL_KEYS: Record = { want_to_read: 'status.want_to_read', currently_reading: 'status.currently_reading', read: 'status.read', - did_not_finish: 'status.did_not_finish' + did_not_finish: 'status.did_not_finish', + all: 'status.all' }; const PAGE_SIZE = 40; - let activeStatus = $derived( - ($page.url.searchParams.get('status') as ReadingStatus) ?? 'want_to_read' + let activeStatus = $derived( + ($page.url.searchParams.get('status') as ReadingStatus | 'all') ?? 'want_to_read' ); let requestedBookId = $derived.by(() => { const raw = $page.url.searchParams.get('bookId'); @@ -84,7 +87,7 @@ return numberFormatter.format(value); } - function getStatusCount(status: ReadingStatus): number | null { + function getStatusCount(status: ReadingStatus | 'all'): number | null { if (!statusCounts) return null; switch (status) { case 'want_to_read': @@ -95,6 +98,8 @@ return statusCounts.books_read; case 'did_not_finish': return statusCounts.books_did_not_finish; + case 'all': + return statusCounts.total_books; } } @@ -111,7 +116,7 @@ let drawerOpen = $state(false); let addBookOpen = $state(false); - function changeTab(status: ReadingStatus) { + function changeTab(status: ReadingStatus | 'all') { if (status === activeStatus) return; void goto(`/library?status=${status}`); } @@ -155,7 +160,7 @@ loading = true; try { const response = await api.books.list({ - status: activeStatus, + status: activeStatus === 'all' ? undefined : activeStatus, acquisition_status: activeStatus === 'want_to_read' && acquisitionFilter ? acquisitionFilter : undefined, q: searchQuery || undefined, smart_sort: smartSort, @@ -189,7 +194,7 @@ loadingMore = true; try { const response = await api.books.list({ - status: activeStatus, + status: activeStatus === 'all' ? undefined : activeStatus, acquisition_status: activeStatus === 'want_to_read' && acquisitionFilter ? acquisitionFilter : undefined, q: searchQuery || undefined, smart_sort: smartSort, @@ -251,7 +256,7 @@ function handleSave(updated: Book) { selectedBook = updated; - if (updated.reading_status !== activeStatus) { + if (activeStatus !== 'all' && updated.reading_status !== activeStatus) { detailOpen = false; drawerOpen = false; books = books.filter((b) => b.id !== updated.id); @@ -273,7 +278,7 @@ } function handleAdded(book: Book) { - if (book.reading_status === activeStatus) { + if (activeStatus === 'all' || book.reading_status === activeStatus) { books = [book, ...books]; } addBookOpen = false; @@ -347,6 +352,7 @@ placeholder={$_('common.searchBooks')} onSearch={(q) => (searchQuery = q)} /> + {#if searchQuery} {totalCount} {totalCount === 1 ? $_('common.result') : $_('common.results')} @@ -399,18 +405,20 @@
- - + + {/if} + - @@ -477,6 +485,6 @@ diff --git a/frontend/src/routes/library/page.test.ts b/frontend/src/routes/library/page.test.ts new file mode 100644 index 00000000..ae0108b2 --- /dev/null +++ b/frontend/src/routes/library/page.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/svelte'; +import LibraryPage from './+page.svelte'; +import type { Book, LibraryStats } from '$lib/types'; + +const mockPage = vi.hoisted(() => { + const subscribers = new Set<(value: unknown) => void>(); + let state = { url: new URL('http://localhost:5173/library'), params: {}, route: { id: null } }; + + return { + subscribe(run: (value: unknown) => void) { + run(state); + subscribers.add(run); + return () => subscribers.delete(run); + }, + setUrl(url: string) { + state = { url: new URL(url), params: {}, route: { id: null } }; + subscribers.forEach((fn) => fn(state)); + } + }; +}); + +vi.mock('$app/stores', () => ({ + page: { subscribe: mockPage.subscribe }, + navigating: { subscribe: vi.fn() } +})); + +const mockGoto = vi.fn(); +vi.mock('$app/navigation', () => ({ + goto: (...args: unknown[]) => mockGoto(...args), + beforeNavigate: () => {}, + afterNavigate: () => {}, + onNavigate: () => () => {} +})); + +const mockBooksList = vi.fn(); +const mockBooksStats = vi.fn(); +const mockProgressLatest = vi.fn(); +vi.mock('$lib/api', () => ({ + api: { + books: { + list: (...args: unknown[]) => mockBooksList(...args), + stats: (...args: unknown[]) => mockBooksStats(...args), + progress: { latest: (...args: unknown[]) => mockProgressLatest(...args) } + } + } +})); + +// Stub child components so the test isolates the page's own tab/search/sort logic +// without pulling in chartjs, barcode scanners, or dialogs that break under jsdom. +function stubComponent(tag: string) { + return () => ({ + render: () => ({ html: `
`, css: { code: '', map: null }, head: '' }) + }); +} +vi.mock('$lib/components/BookCard.svelte', () => ({ default: stubComponent('BookCard') })); +vi.mock('$lib/components/BookListItem.svelte', () => ({ default: stubComponent('BookListItem') })); +vi.mock('$lib/components/BookDetailDialog.svelte', () => ({ default: stubComponent('BookDetailDialog') })); +vi.mock('$lib/components/BookDrawer.svelte', () => ({ default: stubComponent('BookDrawer') })); +vi.mock('$lib/components/AddBookModal.svelte', () => ({ default: stubComponent('AddBookModal') })); +vi.mock('$lib/components/SearchBar.svelte', () => ({ default: stubComponent('SearchBar') })); +vi.mock('$lib/components/SearchHelp.svelte', () => ({ default: stubComponent('SearchHelp') })); + +function createMockBook(id: number, overrides?: Partial): Book { + return { + id, + title: `Book ${id}`, + subtitle: null, + author: 'Test Author', + authors: ['Test Author'], + isbn: null, + cover_url: null, + publisher: null, + published_year: null, + page_count: 100, + language: null, + tags: null, + notes: null, + blurb: null, + rating: null, + reading_status: 'want_to_read', + acquisition_status: 'owned', + date_added: '2025-01-01T00:00:00Z', + date_started: null, + date_finished: null, + ...overrides + }; +} + +function createMockStats(overrides?: Partial): LibraryStats { + return { + total_books: 12, + books_want_to_read: 7, + books_reading: 1, + books_read: 3, + books_did_not_finish: 1, + ...overrides + }; +} + +describe('LibraryPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPage.setUrl('http://localhost:5173/library'); + mockBooksStats.mockResolvedValue(createMockStats()); + mockBooksList.mockResolvedValue({ total: 0, books: [] }); + mockProgressLatest.mockResolvedValue([]); + }); + + afterEach(() => { + cleanup(); + }); + + it('renders all five tabs including All Books with total count', async () => { + render(LibraryPage); + + await waitFor(() => { + expect(screen.getByRole('tab', { name: 'Want to Read (7)' })).toBeInTheDocument(); + }); + expect(screen.getByRole('tab', { name: 'Currently Reading (1)' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Read (3)' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Did Not Finish (1)' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'All Books (12)' })).toBeInTheDocument(); + }); + + it('fetches books without a status filter when status=all', async () => { + mockPage.setUrl('http://localhost:5173/library?status=all'); + mockBooksList.mockResolvedValue({ + total: 2, + books: [createMockBook(1, { reading_status: 'read' }), createMockBook(2, { reading_status: 'want_to_read' })] + }); + + render(LibraryPage); + + await waitFor(() => { + expect(mockBooksList).toHaveBeenCalled(); + }); + const params = mockBooksList.mock.calls[0][0]; + expect(params.status).toBeUndefined(); + expect(screen.getByRole('heading', { name: 'All Books' })).toBeInTheDocument(); + }); + + it('fetches books with a status filter on a status tab', async () => { + mockPage.setUrl('http://localhost:5173/library?status=want_to_read'); + + render(LibraryPage); + + await waitFor(() => { + expect(mockBooksList).toHaveBeenCalled(); + }); + const params = mockBooksList.mock.calls[0][0]; + expect(params.status).toBe('want_to_read'); + }); + + it('clicking the All Books tab navigates to status=all', async () => { + render(LibraryPage); + + const allTab = await screen.findByRole('tab', { name: /All Books/ }); + await fireEvent.click(allTab); + + expect(mockGoto).toHaveBeenCalledWith('/library?status=all'); + }); + + it('hides smart sort and keeps sort selects enabled on the All tab', async () => { + mockPage.setUrl('http://localhost:5173/library?status=all'); + + const { container } = render(LibraryPage); + + await waitFor(() => { + expect(mockBooksList).toHaveBeenCalled(); + }); + expect(container.querySelector('input[name="smart-sort"]')).toBeNull(); + const sortField = container.querySelector('select[name="sort-field"]') as HTMLSelectElement; + const sortOrder = container.querySelector('select[name="sort-order"]') as HTMLSelectElement; + expect(sortField.disabled).toBe(false); + expect(sortOrder.disabled).toBe(false); + }); + + it('shows smart sort on a status tab', async () => { + render(LibraryPage); + + const { container } = render(LibraryPage); + await waitFor(() => { + expect(mockBooksList).toHaveBeenCalled(); + }); + expect(container.querySelector('input[name="smart-sort"]')).not.toBeNull(); + }); +}); \ No newline at end of file diff --git a/frontend/src/routes/missing-covers/+page.svelte b/frontend/src/routes/missing-covers/+page.svelte index 0f192dad..b4b26fc1 100644 --- a/frontend/src/routes/missing-covers/+page.svelte +++ b/frontend/src/routes/missing-covers/+page.svelte @@ -6,6 +6,7 @@ import type { Book, CoverCandidate } from '$lib/types'; import { ArrowLeft, ExternalLink, SkipForward } from '@lucide/svelte'; import CoverCandidateGrid from '$lib/components/CoverCandidateGrid.svelte'; + import { formatAuthors } from '$lib/utils/authors'; let loading = $state(true); let advancing = $state(false); @@ -26,7 +27,7 @@ const hasMoreBooks = $derived(currentIndex < totalMissing); const googleSearchUrl = $derived.by(() => { if (!currentBook) return ''; - const parts = [currentBook.title, currentBook.author].filter(Boolean); + const parts = [currentBook.title, formatAuthors(currentBook.authors, currentBook.author)].filter(Boolean); return `https://www.google.com/search?q=${encodeURIComponent(parts.join(' '))}&udm=2&tbs=isz:l`; }); @@ -221,7 +222,7 @@ {:else}

{currentBook.title}

-

{currentBook.author}

+

{formatAuthors(currentBook.authors, currentBook.author)}

{currentBook.isbn ? $_('missingCovers.isbnLabel', { values: { isbn: currentBook.isbn } }) : $_('missingCovers.noIsbn')}

diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index b4851a62..083cdaad 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -37,6 +37,17 @@ let themeMode = $state(getThemeMode()); let customTheme = $state(getCustomTheme() ?? 'dracula'); let themeMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); + let goalPagesPerDayEnabled = $state(false); + let goalPagesPerDay = $state(20); + let goalPagesPerMonthEnabled = $state(false); + let goalPagesPerMonth = $state(300); + let goalBooksPerMonthEnabled = $state(false); + let goalBooksPerMonth = $state(2); + let goalBooksPerYearEnabled = $state(false); + let goalBooksPerYear = $state(25); + let gamificationEnabled = $state(true); + let goalsMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); + let goalsSaving = $state(false); let resetDataConfirmation = $state(''); let resetDataMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); let deleteAccountConfirmation = $state(''); @@ -124,6 +135,15 @@ customTheme = settings.custom_theme; setCustomTheme(customTheme); } + goalPagesPerDayEnabled = settings.goal_pages_per_day_enabled; + goalPagesPerDay = settings.goal_pages_per_day; + goalPagesPerMonthEnabled = settings.goal_pages_per_month_enabled; + goalPagesPerMonth = settings.goal_pages_per_month; + goalBooksPerMonthEnabled = settings.goal_books_per_month_enabled; + goalBooksPerMonth = settings.goal_books_per_month; + goalBooksPerYearEnabled = settings.goal_books_per_year_enabled; + goalBooksPerYear = settings.goal_books_per_year; + gamificationEnabled = settings.gamification_enabled; applyThemeToDocument(); saveThemeToStorage(); saveRestorePoint(); @@ -227,6 +247,40 @@ } } + async function saveGoals() { + goalsMessage = null; + const targets = [ + { enabled: goalPagesPerDayEnabled, value: goalPagesPerDay, key: $_('profile.goals.pagesPerDay') }, + { enabled: goalPagesPerMonthEnabled, value: goalPagesPerMonth, key: $_('profile.goals.pagesPerMonth') }, + { enabled: goalBooksPerMonthEnabled, value: goalBooksPerMonth, key: $_('profile.goals.booksPerMonth') }, + { enabled: goalBooksPerYearEnabled, value: goalBooksPerYear, key: $_('profile.goals.booksPerYear') } + ]; + const invalid = targets.find((t) => t.enabled && (!Number.isInteger(t.value) || t.value < 1)); + if (invalid) { + goalsMessage = { type: 'error', text: $_('profile.goals.invalidTarget', { values: { name: invalid.key } }) }; + return; + } + goalsSaving = true; + try { + await api.profile.updateSettings({ + goal_pages_per_day_enabled: goalPagesPerDayEnabled, + goal_pages_per_day: goalPagesPerDay, + goal_pages_per_month_enabled: goalPagesPerMonthEnabled, + goal_pages_per_month: goalPagesPerMonth, + goal_books_per_month_enabled: goalBooksPerMonthEnabled, + goal_books_per_month: goalBooksPerMonth, + goal_books_per_year_enabled: goalBooksPerYearEnabled, + goal_books_per_year: goalBooksPerYear, + gamification_enabled: gamificationEnabled + }); + goalsMessage = { type: 'success', text: $_('profile.goals.saveSuccess') }; + } catch (e: unknown) { + goalsMessage = { type: 'error', text: e instanceof Error ? e.message : $_('common.saveFailed') }; + } finally { + goalsSaving = false; + } + } + async function createKey() { const result = await api.profile.createApiKey({ description: description || null }); createdKey = result.key; @@ -565,6 +619,104 @@
+
+
+

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

+

{$_('profile.goals.subtitle')}

+ {#if goalsMessage} + (goalsMessage = null)}> + {goalsMessage.text} + + {/if} +
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+

{$_('user.apiKeys')}

@@ -852,6 +1004,7 @@
  • {$_('settings.languageTitle')}
  • {$_('settings.timezone')}
  • {$_('settings.themeTitle')}
  • +
  • {$_('profile.goals.title')}
  • {$_('user.apiKeys')}
  • {$_('user.embedTokens')}
  • {$_('profile.dataManagement.title')}
  • diff --git a/frontend/src/routes/search/+page.svelte b/frontend/src/routes/search/+page.svelte index cb1239a2..879e19e5 100644 --- a/frontend/src/routes/search/+page.svelte +++ b/frontend/src/routes/search/+page.svelte @@ -10,6 +10,7 @@ import BookCard from '$lib/components/BookCard.svelte'; import BookDetailDialog from '$lib/components/BookDetailDialog.svelte'; import BookDrawer from '$lib/components/BookDrawer.svelte'; + import SearchHelp from '$lib/components/SearchHelp.svelte'; import { Search, ArrowLeft, X } from '@lucide/svelte'; const PAGE_SIZE = 40; @@ -216,6 +217,8 @@ {/if}
    + +
    diff --git a/frontend/src/routes/search/page.test.ts b/frontend/src/routes/search/page.test.ts index 0725cdf5..4fd0db34 100644 --- a/frontend/src/routes/search/page.test.ts +++ b/frontend/src/routes/search/page.test.ts @@ -48,6 +48,7 @@ function createMockBook(id: number, overrides?: Partial): Book { title: `Book ${id}`, subtitle: null, author: 'Test Author', + authors: ['Test Author'], isbn: null, cover_url: null, publisher: null, @@ -99,12 +100,30 @@ describe('SearchPage', () => { }); }); + it('passes prefixed query from URL to the API unchanged', async () => { + mockPage.setUrl('http://localhost:5173/search?q=author%3A%22Marlen%20Haushofer%22%20-tag%3Acars'); + + mockBooksList.mockResolvedValue({ total: 0, books: [] }); + + render(SearchPage); + + await waitFor(() => { + expect(mockBooksList).toHaveBeenCalledWith( + expect.objectContaining({ + q: 'author:"Marlen Haushofer" -tag:cars', + offset: 0, + limit: 40 + }) + ); + }); + }); + it('displays search results', async () => { mockPage.setUrl('http://localhost:5173/search?q=Dune'); mockBooksList.mockResolvedValue({ total: 1, - books: [createMockBook(1, { title: 'Dune', author: 'Frank Herbert' })] + books: [createMockBook(1, { title: 'Dune', author: 'Frank Herbert', authors: ['Frank Herbert'] })] }); render(SearchPage); diff --git a/frontend/src/routes/statistics/+page.svelte b/frontend/src/routes/statistics/+page.svelte index 5283483c..bfe4cea4 100644 --- a/frontend/src/routes/statistics/+page.svelte +++ b/frontend/src/routes/statistics/+page.svelte @@ -230,7 +230,7 @@
    {:else} -
    +
    {$_('statistics.avgBooksPerMonth')}
    {formatNumber(stats.avg_books_per_month, 2, 1)}
    @@ -251,6 +251,11 @@
    {formatNumber(stats.most_popular_language_count, 0)}
    +
    +
    {$_('statistics.totalBooksAndAuthors')}
    +
    {formatNumber(stats.total_books, 0)}
    +
    {$_('statistics.booksFromAuthors', { values: { authors: stats.total_authors } })}
    +
    {$_('statistics.sectionDistributions')}
    diff --git a/frontend/src/routes/statistics/page.test.ts b/frontend/src/routes/statistics/page.test.ts index 359cd5af..0503d774 100644 --- a/frontend/src/routes/statistics/page.test.ts +++ b/frontend/src/routes/statistics/page.test.ts @@ -16,6 +16,8 @@ vi.mock('$lib/api', () => ({ function createMockStats(overrides?: Partial): StatisticsResponse { return { + total_books: 3, + total_authors: 2, avg_books_per_month: 1, busiest_month: '2026-01', busiest_month_count: 2, diff --git a/frontend/src/routes/timeline/+page.svelte b/frontend/src/routes/timeline/+page.svelte index b0a81846..31b8e63f 100644 --- a/frontend/src/routes/timeline/+page.svelte +++ b/frontend/src/routes/timeline/+page.svelte @@ -7,6 +7,7 @@ import { toasts } from '$lib/toasts'; import { shouldShowActionToast } from '$lib/errors'; import { getTimezone } from '$lib/stores/timezone'; + import { formatAuthors } from '$lib/utils/authors'; import BookDetailDialog from '$lib/components/BookDetailDialog.svelte'; import BookDrawer from '$lib/components/BookDrawer.svelte'; @@ -259,7 +260,7 @@

    {item.book.title}

    {#if item.book.author} -

    {item.book.author}

    +

    {formatAuthors(item.book.authors, item.book.author)}

    {/if} {#if item.book.rating}

    {stars(item.book.rating)}