diff --git a/README.md b/README.md index 384d042..622d368 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@

Tests - Docker Build + Docker Build Docs Build Python Svelte diff --git a/backend/app/database.py b/backend/app/database.py index 7e9b333..3c9bae5 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -5,11 +5,30 @@ from app.config import settings + +def _set_sqlite_pragmas(dbapi_connection, _connection_record) -> None: + """Apply performance-oriented PRAGMAs to new SQLite connections.""" + import sqlite3 + if not isinstance(dbapi_connection, sqlite3.Connection): + return + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA synchronous=NORMAL") + cursor.execute("PRAGMA cache_size=-8000") + cursor.execute("PRAGMA temp_store=MEMORY") + cursor.execute("PRAGMA mmap_size=268435456") + cursor.close() + + engine = create_engine( settings.database_url, connect_args={"check_same_thread": False}, # needed for SQLite + pool_pre_ping=True, ) +from sqlalchemy import event # noqa: E402 +event.listen(engine, "connect", _set_sqlite_pragmas) + @atexit.register def _dispose_engine() -> None: diff --git a/backend/app/models.py b/backend/app/models.py index c335aa0..c04369c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -5,6 +5,7 @@ from datetime import datetime, timezone import sqlalchemy as sa +from pydantic import model_validator from sqlalchemy import Column, DateTime, TypeDecorator from sqlmodel import Field, SQLModel @@ -60,6 +61,13 @@ class Book(SQLModel, table=True): isbn: Optional[str] = Field(default=None, unique=True) cover_url: Optional[str] = None publisher: Optional[str] = None + + @model_validator(mode="before") + @classmethod + def normalize_empty_cover_url(cls, data: dict) -> dict: + if isinstance(data, dict) and "cover_url" in data and data["cover_url"] == "": + data["cover_url"] = None + return data published_year: Optional[int] = None page_count: int = Field(default=0) language: Optional[str] = Field(default=None, max_length=2) @@ -103,7 +111,7 @@ class BookTag(SQLModel, table=True): __tablename__ = "book_tag" book_id: int = Field(foreign_key="book.id", primary_key=True) - tag_id: int = Field(foreign_key="tag.id", primary_key=True) + tag_id: int = Field(foreign_key="tag.id", primary_key=True, index=True) class User(SQLModel, table=True): diff --git a/backend/app/routers/books.py b/backend/app/routers/books.py index 7409720..5feead5 100644 --- a/backend/app/routers/books.py +++ b/backend/app/routers/books.py @@ -5,6 +5,7 @@ from typing import List, Literal, Optional from fastapi import APIRouter, Depends, HTTPException, Query, status +import sqlalchemy as sa from sqlalchemy.exc import IntegrityError from sqlmodel import Session, func, or_, select @@ -31,7 +32,7 @@ ) 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.tags import build_book_read, cleanup_orphan_tags, sync_book_tags +from app.services.tags import build_book_read, cleanup_orphan_tags, load_tags_batch, sync_book_tags from app.time_utils import utcnow logger = logging.getLogger(__name__) @@ -128,10 +129,19 @@ 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.""" + payload = book.model_dump() + payload.pop("user_id", None) + payload["tags"] = tags_text + return BookRead.model_validate(payload) + + @router.get("", response_model=BookListResponse) def list_books( status: Optional[ReadingStatus] = Query(default=None), q: Optional[str] = Query(default=None), + has_cover: Optional[bool] = Query(default=None), sort: Literal["title", "date_added", "date_started", "date_finished", "rating"] = Query( default="date_added" ), @@ -173,6 +183,14 @@ def list_books( ) ) + if has_cover is not None: + if has_cover: + base_statement = base_statement.where(Book.cover_url.is_not(None), Book.cover_url != "") + else: + base_statement = base_statement.where( + sa.or_(Book.cover_url.is_(None), Book.cover_url == "") + ) + total = session.exec( select(func.count()).select_from(base_statement.subquery()) ).one() @@ -206,8 +224,13 @@ def list_books( books = list(session.exec(statement).all()) 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 {} return BookListResponse( - books=[build_book_read(session, book) for book in books], + books=[ + _build_book_read_with_tags(book, book_tags_map.get(book.id)) + for book in books + ], total=total, ) @@ -279,12 +302,13 @@ def get_tag_cloud( session: Session = Depends(get_session), ) -> List[TagCloudEntry]: """Return tags sorted by usage count (descending) for the authenticated user.""" + count_label = func.count(BookTag.book_id).label("cnt") rows = session.exec( - select(Tag.name, func.count(BookTag.book_id)) + select(Tag.name, count_label) .join(BookTag, BookTag.tag_id == Tag.id) .where(Tag.user_id == current_user.id) - .group_by(Tag.id, Tag.name) - .order_by(func.count(BookTag.book_id).desc(), Tag.name.asc()) + .group_by(Tag.id) + .order_by(count_label.desc(), Tag.name.asc()) .limit(limit) ).all() return [TagCloudEntry(tag=name, count=count) for name, count in rows] diff --git a/backend/app/routers/data.py b/backend/app/routers/data.py index 9fb291f..c4463ef 100644 --- a/backend/app/routers/data.py +++ b/backend/app/routers/data.py @@ -293,6 +293,7 @@ async def execute_import_data( async def event_generator(): with Session(stream_bind) as session: completed = False + import_failed_rows = 0 final_error: str | None = None try: async for event in execute_import( @@ -305,6 +306,7 @@ async def event_generator(): ): if event.get("event") == "complete": completed = True + import_failed_rows = event.get("failed", 0) if event.get("event") == "error": final_error = str(event.get("message") or "Import failed") yield f"data: {json.dumps(event)}\n\n" @@ -321,7 +323,7 @@ async def event_generator(): final_error = 'error.importExecutionFailed' yield f"data: {json.dumps({'event': 'error', 'message': 'error.importExecutionFailed'})}\n\n" finally: - if completed or final_error is not None: + if completed and import_failed_rows == 0 and final_error is None: delete_parsed_upload(body.file_id, current_user.id) return StreamingResponse( diff --git a/backend/app/routers/statistics.py b/backend/app/routers/statistics.py index 23b1bd7..3d1576d 100644 --- a/backend/app/routers/statistics.py +++ b/backend/app/routers/statistics.py @@ -1,5 +1,6 @@ """Statistics dashboard — full stats, pages-per-day breakdown, and book-level fallback.""" +import calendar from collections import Counter from datetime import datetime, timedelta, timezone from statistics import mean @@ -25,6 +26,7 @@ StatusDistribution, TopAuthor, TopAuthorCover, + TopRatedBook, YearlyBooks, ) @@ -62,8 +64,53 @@ def _month_range(start_key: str, end_key: str) -> list[str]: return keys -def _extract_progress_daily_pages(entries: list, tz: ZoneInfo) -> Counter[str]: - """Distribute reading progress page-deltas across calendar days.""" +def _clamp_window( + start: datetime, end: datetime, + window_start: datetime | None, window_end: datetime | None, +) -> tuple[datetime | None, datetime | None]: + """Clamp *start*/*end* to *window_start*/*window_end* if provided. + + Returns (clamped_start, clamped_end) or (None, None) when the span + does not overlap the window at all. + All returned datetimes are UTC-aware (matching the DB convention) + so callers can safely use .astimezone() and compare. + """ + if window_start is not None: + w_start = _naive_utc(window_start) + s = _naive_utc(start) + e = _naive_utc(end) + if e < w_start: + return (None, None) + if s < w_start: + start = w_start.replace(tzinfo=timezone.utc) + if window_end is not None: + w_end = _naive_utc(window_end) + s = _naive_utc(start) + e = _naive_utc(end) + if s > w_end: + return (None, None) + if e > w_end: + end = w_end.replace(tzinfo=timezone.utc) + return (start, end) + + +def _naive_utc(dt: datetime) -> datetime: + """Return a naive datetime representing the same instant as *dt* in UTC.""" + if dt.tzinfo is not None: + return dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt + + +def _extract_progress_daily_pages( + entries: list, tz: ZoneInfo, + window_start: datetime | None = None, window_end: datetime | None = None, +) -> Counter[str]: + """Distribute reading progress page-deltas across calendar days. + + When *window_start*/*window_end* are provided, only days within that + window are emitted. The daily average is still computed from the full + span so the values stay correct. + """ daily: Counter[str] = Counter() grouped: dict[int, list] = {} for entry in entries: @@ -78,18 +125,27 @@ def _extract_progress_daily_pages(entries: list, tz: ZoneInfo) -> Counter[str]: day_diff = (curr.created_at - prev.created_at).days + 1 if day_diff > 0: daily_avg = delta / day_diff - current = prev.created_at - end_dt = curr.created_at - while current <= end_dt: - date_key = current.astimezone(tz).strftime("%Y-%m-%d") + start, end = _clamp_window(prev.created_at, curr.created_at, window_start, window_end) + if start is None: + continue + while start <= end: + date_key = start.astimezone(tz).strftime("%Y-%m-%d") daily[date_key] += daily_avg - current += timedelta(days=1) + start += timedelta(days=1) return daily -def _extract_book_level_daily_pages(books: list[Book], tz: ZoneInfo) -> Counter[str]: - """Distribute page counts across the reading period for books finished without progress entries.""" +def _extract_book_level_daily_pages( + books: list[Book], tz: ZoneInfo, + window_start: datetime | None = None, window_end: datetime | None = None, +) -> Counter[str]: + """Distribute page counts across the reading period for books finished without progress entries. + + When *window_start*/*window_end* are provided, only days within that + window are emitted. The daily average is still computed from the full + span so the values stay correct. + """ daily: Counter[str] = Counter() for book in books: if not (book.date_started and book.date_finished and book.page_count): @@ -100,14 +156,72 @@ def _extract_book_level_daily_pages(books: list[Book], tz: ZoneInfo) -> Counter[ if total_days <= 0: continue daily_avg = book.page_count / total_days - current = book.date_started - while current <= book.date_finished: - date_key = current.astimezone(tz).strftime("%Y-%m-%d") + start, end = _clamp_window(book.date_started, book.date_finished, window_start, window_end) + if start is None: + continue + while start <= end: + date_key = start.astimezone(tz).strftime("%Y-%m-%d") daily[date_key] += daily_avg - current += timedelta(days=1) + start += timedelta(days=1) return daily +def _allocate_daily_avg_across_months( + daily_avg: float, start: datetime, end: datetime, tz: ZoneInfo +) -> Counter[str]: + """Spread a per-day value proportionally across months from *start* to *end* inclusive.""" + monthly: Counter[str] = Counter() + current = start + while current <= end: + _, last_dom = calendar.monthrange(current.year, current.month) + period_end = min(current.replace(day=last_dom), end) + days = (period_end - current).days + 1 + month_key = _month_key(current, tz) + monthly[month_key] += daily_avg * days + current = period_end + timedelta(days=1) + return monthly + + +def _compute_pages_per_month_from_progress(entries: list, tz: ZoneInfo) -> Counter[str]: + """Compute pages read per month from reading progress entries.""" + monthly: Counter[str] = Counter() + grouped: dict[int, list] = {} + for entry in entries: + grouped.setdefault(entry.book_id, []).append(entry) + for book_id in sorted(grouped): + book_entries = sorted(grouped[book_id], 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: + continue + day_diff = (curr.created_at - prev.created_at).days + 1 + if day_diff <= 0: + continue + m = _allocate_daily_avg_across_months(delta / day_diff, prev.created_at, curr.created_at, tz) + for k, v in m.items(): + monthly[k] += v + return monthly + + +def _compute_pages_per_month_from_books(books: list[Book], tz: ZoneInfo) -> Counter[str]: + """Compute pages read per month for finished books without progress entries.""" + monthly: Counter[str] = Counter() + for book in books: + if not (book.date_started and book.date_finished and book.page_count): + continue + if book.date_finished < book.date_started: + continue + total_days = (book.date_finished - book.date_started).days + 1 + if total_days <= 0: + continue + m = _allocate_daily_avg_across_months( + book.page_count / total_days, book.date_started, book.date_finished, tz + ) + for k, v in m.items(): + monthly[k] += v + return monthly + + @router.get("/pages-per-day", response_model=DailyPagesResponse) def get_pages_per_day( days: int = Query(default=365, ge=1, le=730), @@ -146,17 +260,23 @@ def get_pages_per_day( virtual_entries = [] for book in books: - if book.id in books_with_progress and book.date_started: - virtual_entries.append( - SimpleNamespace( - book_id=book.id, - page=0, - created_at=book.date_started, - ) + if book.id not in books_with_progress or not book.date_started: + continue + # Finished books without date_finished have no bounded reading + # period; skip to avoid spreading pages from date_started to + # today via a single import-created progress entry. + if book.reading_status == ReadingStatus.read and not book.date_finished: + continue + virtual_entries.append( + SimpleNamespace( + book_id=book.id, + page=0, + created_at=book.date_started, ) + ) all_progress_entries = list(progress_entries) + virtual_entries - progress_daily = _extract_progress_daily_pages(all_progress_entries, tz) + progress_daily = _extract_progress_daily_pages(all_progress_entries, tz, start_date_utc, end_date_utc) fallback_books = [ b @@ -167,7 +287,7 @@ def get_pages_per_day( and b.date_finished and b.page_count ] - fallback_daily = _extract_book_level_daily_pages(fallback_books, tz) + fallback_daily = _extract_book_level_daily_pages(fallback_books, tz, start_date_utc, end_date_utc) combined: Counter[str] = Counter() for k, v in progress_daily.items(): @@ -280,17 +400,20 @@ def get_statistics( virtual_entries = [] for book in books: - if book.id in books_with_progress and book.date_started: - virtual_entries.append( - SimpleNamespace( - book_id=book.id, - page=0, - created_at=book.date_started, - ) + if book.id not in books_with_progress or not book.date_started: + continue + if book.reading_status == ReadingStatus.read and not book.date_finished: + continue + virtual_entries.append( + SimpleNamespace( + book_id=book.id, + page=0, + created_at=book.date_started, ) + ) all_progress_entries = list(progress_entries) + virtual_entries - progress_daily = _extract_progress_daily_pages(all_progress_entries, tz) + pages_read_per_month_counter = _compute_pages_per_month_from_progress(all_progress_entries, tz) fallback_books = [ b @@ -301,18 +424,9 @@ def get_statistics( and b.date_finished and b.page_count ] - fallback_daily = _extract_book_level_daily_pages(fallback_books, tz) - - combined_daily: Counter[str] = Counter() - for k, v in progress_daily.items(): - combined_daily[k] += v - for k, v in fallback_daily.items(): - combined_daily[k] += v - - pages_read_per_month_counter: Counter[str] = Counter() - for date_str, pages in combined_daily.items(): - month_key = date_str[:7] - pages_read_per_month_counter[month_key] += pages + fallback_monthly = _compute_pages_per_month_from_books(fallback_books, tz) + for k, v in fallback_monthly.items(): + pages_read_per_month_counter[k] += v if finished_books_per_month: avg_books_per_month = round( @@ -373,21 +487,40 @@ def get_statistics( covers_by_author: dict[str, list[TopAuthorCover]] = {} for author_name in top_author_names: - author_cover_rows = session.exec( - select(Book.id, Book.reading_status, Book.cover_url) + max_slots = min(5, author_counts[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, Book.cover_url.is_not(None), ) .order_by(Book.id) - .limit(5) + .limit(max_slots) ).all() - covers_by_author[author_name] = [ - TopAuthorCover(book_id=book_id, reading_status=reading_status, cover_url=cover_url) - for book_id, reading_status, cover_url in author_cover_rows - if cover_url and book_id is not None + results = [ + TopAuthorCover(book_id=book_id, title=title, reading_status=reading_status, cover_url=cover_url) + for book_id, title, reading_status, cover_url in cover_rows + if book_id is not None ] + remaining = max_slots - len(results) + if remaining > 0: + no_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, + Book.cover_url.is_(None), + ) + .order_by(Book.id) + .limit(remaining) + ).all() + results.extend( + TopAuthorCover(book_id=book_id, title=title, reading_status=reading_status, cover_url=cover_url) + for book_id, title, reading_status, cover_url in no_cover_rows + if book_id is not None + ) + covers_by_author[author_name] = results top_authors = [ TopAuthor( @@ -398,6 +531,24 @@ def get_statistics( for author_name, author_count in top_author_counts ] + # --- Rating stats --- + books_with_rating = sum(1 for b in books if b.rating is not None) + books_without_rating = sum(1 for b in books if b.rating is None) + rating_values = [b.rating for b in books if b.rating is not None] + 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] + + top_rated_books = [ + 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) + for b in sorted(rated_books, key=lambda x: (-x.rating, -(x.date_added or datetime.min).timestamp())) + ] + + worst_rated_books = [ + 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) + for b in sorted(rated_books, key=lambda x: (x.rating, -(x.date_added or datetime.min).timestamp())) + ] + return StatisticsResponse( avg_books_per_month=avg_books_per_month, busiest_month=busiest_month, @@ -412,4 +563,9 @@ def get_statistics( books_finished_per_month=books_finished_per_month, books_finished_per_year=books_finished_per_year, top_authors=top_authors, + books_with_rating=books_with_rating, + books_without_rating=books_without_rating, + average_rating=average_rating, + top_rated_books=top_rated_books, + worst_rated_books=worst_rated_books, ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 22a9f59..80810a6 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -223,8 +223,19 @@ class TopAuthor(SQLModel): class TopAuthorCover(SQLModel): """Cover reference for a book by a top author.""" book_id: int + title: str reading_status: ReadingStatus - cover_url: str + cover_url: str | None + + +class TopRatedBook(SQLModel): + """A book appearing in top/worst rated lists.""" + book_id: int + title: str + author: Optional[str] + rating: int + reading_status: ReadingStatus + cover_url: Optional[str] class StatisticsResponse(SQLModel): @@ -242,6 +253,11 @@ class StatisticsResponse(SQLModel): books_finished_per_month: list[MonthlyBooks] books_finished_per_year: list[YearlyBooks] top_authors: list[TopAuthor] + books_with_rating: int + books_without_rating: int + average_rating: Optional[float] + top_rated_books: list[TopRatedBook] + worst_rated_books: list[TopRatedBook] class UserLogin(SQLModel): diff --git a/backend/app/services/backup_restore.py b/backend/app/services/backup_restore.py index b404169..2b4f7be 100644 --- a/backend/app/services/backup_restore.py +++ b/backend/app/services/backup_restore.py @@ -26,10 +26,77 @@ _LOCK_FILE: str = "backup_restore.lock" +def _remove_wal_files(db_path: str) -> None: + """Remove stale WAL and SHM files left by a WAL-mode database. + + Must be called after replacing the database file on disk and before + opening any new connection, otherwise SQLite will try to replay the old + WAL into the new database — causing B‑tree corruption. + """ + for suffix in ("-wal", "-shm"): + path = f"{db_path}{suffix}" + if os.path.isfile(path): + os.remove(path) + + +def _run_alembic_migrations() -> None: + """Run all pending alembic migrations to bring the database schema up to date. + + This is necessary when restoring a backup from an older release whose schema + may be behind the current codebase. + """ + from alembic.config import Config + from alembic.command import upgrade + + alembic_cfg = Config(os.path.join(os.path.dirname(__file__), "..", "..", "alembic.ini")) + upgrade(alembic_cfg, "head") + + +def _stamp_alembic_head_if_fresh() -> None: + """Stamp alembic_version at the current head when the version table is absent + or empty. + + After ``SQLModel.metadata.create_all()`` creates tables directly (bypassing + alembic), alembic would otherwise try to re-run the initial migration and fail + with *table already exists*. + + When ``alembic_version`` already has a row (e.g. an old backup was restored), + stamping is skipped so that ``alembic upgrade head`` can apply pending + migrations on top of whatever revision the backup was at. + """ + from alembic.config import Config + from alembic import command + from alembic.script import ScriptDirectory + + import sqlite3 + + db_path = _extract_db_path(settings.database_url) + conn = sqlite3.connect(db_path) + try: + cursor = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='alembic_version'" + ) + table_exists = cursor.fetchone() is not None + if table_exists: + row_count = conn.execute("SELECT COUNT(*) FROM alembic_version").fetchone()[0] + if row_count > 0: + return + finally: + conn.close() + + alembic_cfg = Config(os.path.join(os.path.dirname(__file__), "..", "..", "alembic.ini")) + script = ScriptDirectory.from_config(alembic_cfg) + head = script.get_current_head() + if head: + command.stamp(alembic_cfg, head) + + def _recreate_engine() -> None: """Replace the global SQLAlchemy engine with a fresh one. Called after restoring the database on disk so the app picks up the new data. + Also runs pending alembic migrations for backward compatibility with backups + created by older releases. """ from app.database import create_engine as _create_engine from sqlmodel import SQLModel @@ -39,6 +106,8 @@ def _recreate_engine() -> None: connect_args={"check_same_thread": False}, ) SQLModel.metadata.create_all(new_engine) + _stamp_alembic_head_if_fresh() + _run_alembic_migrations() db_mod.engine = new_engine @@ -272,6 +341,7 @@ def _rollback_safety_backup(safety_dir: str, database_url: str, data_dir: str) - safety_db = os.path.join(safety_dir, "database.db") if os.path.isfile(safety_db): shutil.copy2(safety_db, db_path) + _remove_wal_files(db_path) for item_name in os.listdir(safety_dir): if item_name == "database.db": continue @@ -368,6 +438,8 @@ def restore_backup( os.remove(tmp_path) raise + _remove_wal_files(db_path) + tmp_db_path = _extract_db_path(database_url) # NOTE: sqlite3.connect() context manager manages transactions, NOT # the connection itself. See note in _vacuum_into_backup above. diff --git a/backend/app/services/data_import.py b/backend/app/services/data_import.py index a26c935..4e75371 100644 --- a/backend/app/services/data_import.py +++ b/backend/app/services/data_import.py @@ -3,6 +3,7 @@ import csv import hashlib import json +import logging import re import secrets from datetime import datetime, timezone @@ -16,9 +17,12 @@ from app.config import settings from app.models import Book, ReadingProgress, ReadingStatus, User from app.schemas import ImportFieldConfig + +logger = logging.getLogger(__name__) from app.time_utils import utcnow from app.services.cover_storage import download_cover from app.services.tags import sync_book_tags +from app.services.isbn_utils import normalize_isbn BOOK_IMPORT_FIELDS: list[str] = [ "title", @@ -491,18 +495,38 @@ def validate_import( _parse_year(row_data.get("published_year"), "published_year") _parse_int(row_data.get("page_count"), "page_count") reading_status = _parse_reading_status(row_data.get("reading_status")) - date_started = _parse_datetime(row_data.get("date_started"), "date_started") - date_finished = _parse_datetime(row_data.get("date_finished"), "date_finished") - if date_started and date_finished and date_started > date_finished: - errors.append(f"Row {idx}: date_started is after date_finished") - continue _normalize_language( None if row_data.get("language") is None else str(row_data.get("language")) ) + isbn_raw = row_data.get("isbn") + if isbn_raw: + normalize_isbn(str(isbn_raw)) + except ValueError as exc: + errors.append(f"Row {idx}: {exc}") + continue + + date_started: datetime | None = None + try: + date_started = _parse_datetime(row_data.get("date_started"), "date_started") except ValueError as exc: errors.append(f"Row {idx}: {exc}") + + date_finished: datetime | None = None + try: + date_finished = _parse_datetime(row_data.get("date_finished"), "date_finished") + except ValueError as exc: + errors.append(f"Row {idx}: {exc}") + + if date_started and date_finished and date_started > date_finished: + errors.append(f"Row {idx}: date_started is after date_finished") continue + if reading_status == ReadingStatus.read and not date_finished: + warnings.append( + f"Row {idx}: marked as 'read' but has no finished date; " + "without a finish date the book will not count toward monthly statistics" + ) + 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") @@ -572,17 +596,37 @@ def preview_import( row_errors.append("Rating out of range, will be ignored") _parse_year(row_data.get("published_year"), "published_year") _parse_int(row_data.get("page_count"), "page_count") - _parse_reading_status(row_data.get("reading_status")) - date_started = _parse_datetime(row_data.get("date_started"), "date_started") - date_finished = _parse_datetime(row_data.get("date_finished"), "date_finished") - if date_started and date_finished and date_started > date_finished: - row_errors.append("date_started is after date_finished") + reading_status = _parse_reading_status(row_data.get("reading_status")) _normalize_language( None if row_data.get("language") is None else str(row_data.get("language")) ) + isbn_raw = row_data.get("isbn") + if isbn_raw: + normalize_isbn(str(isbn_raw)) + 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") + except ValueError as exc: + row_errors.append(str(exc)) + + date_finished: datetime | None = None + try: + date_finished = _parse_datetime(row_data.get("date_finished"), "date_finished") except ValueError as exc: row_errors.append(str(exc)) + if date_started and date_finished and date_started > date_finished: + row_errors.append("date_started is after date_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 @@ -659,11 +703,28 @@ async def execute_import( language = _normalize_language( None if row_data.get("language") is None else str(row_data.get("language")) ) - date_started = _parse_datetime(row_data.get("date_started"), "date_started") - date_finished = _parse_datetime(row_data.get("date_finished"), "date_finished") + date_errors: list[str] = [] + date_started: datetime | None = None + try: + date_started = _parse_datetime(row_data.get("date_started"), "date_started") + except ValueError as exc: + date_errors.append(str(exc)) + + date_finished: datetime | None = None + try: + date_finished = _parse_datetime(row_data.get("date_finished"), "date_finished") + except ValueError as exc: + date_errors.append(str(exc)) + + if date_errors: + raise ValueError("; ".join(date_errors)) + if date_started and date_finished and date_started > date_finished: raise ValueError("date_started is after date_finished") + if reading_status == ReadingStatus.read and not date_finished: + raise ValueError("Marked as 'read' but has no finished date") + cover_url = None raw_cover = row_data.get("cover_url") if raw_cover: @@ -695,8 +756,8 @@ async def execute_import( session.add(book) session.flush() - if create_progress_for_read and reading_status == ReadingStatus.read and page_count is not None: - log_date = date_finished if date_finished is not None else utcnow() + 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: log_date = log_date.replace(tzinfo=timezone.utc) progress_entry = ReadingProgress( @@ -763,7 +824,7 @@ async def execute_import( "title": {"source": "Title", "transform": None}, "subtitle": {"source": "", "transform": None}, "author": {"source": "Author", "transform": None}, - "isbn": {"source": "ISBN13", "transform": None}, + "isbn": {"source": "ISBN13", "transform": "value.replace('=', '').replace('\"', '').strip() if value else None"}, "publisher": {"source": "Publisher", "transform": None}, "published_year": { "source": "Original Publication Year", @@ -779,7 +840,7 @@ async def execute_import( "blurb": {"source": "", "transform": None}, "rating": { "source": "My Rating", - "transform": "str(int(value)) if value and str(value).strip() else None", + "transform": "None if not value or value.strip() == '0' else value.strip()", }, "reading_status": { "source": "Exclusive Shelf", @@ -790,8 +851,23 @@ async def execute_import( "return status" ), }, - "date_started": {"source": "Date Added", "transform": None}, - "date_finished": {"source": "Date Read", "transform": None}, + "date_started": {"source": "Date Added", "transform": "value.replace('/', '-') if value else None"}, + "date_finished": { + "source": "Date Read", + "transform": ( + "df = None if not value or (row.get('Date Added') " + "and value < row['Date Added']) " + "else value.replace('/', '-')\n" + "# Uncomment the following lines, to add date_finished to \"Date Added + 1 day\" " + "in case Good Reads set status == \"read\" but did not provide a finish date\n" + "#if not df and row.get(\"Date Added\") and row.get(\"Exclusive Shelf\") == \"read\":\n" + "#\t# Fallback to date_added + 1\n" + "#\tfrom datetime import datetime, timedelta\n" + "#\treturn (datetime.strptime(row[\"Date Added\"], " + "\"%Y/%m/%d\") + timedelta(days=1)).strftime(\"%Y-%m-%d\")\n" + "return df" + ), + }, "cover_url": {"source": "", "transform": None}, }, }, diff --git a/backend/app/services/tags.py b/backend/app/services/tags.py index 5a29a79..ee93602 100644 --- a/backend/app/services/tags.py +++ b/backend/app/services/tags.py @@ -103,6 +103,25 @@ def tags_text_for_book(session: Session, book_id: int) -> str | None: return ", ".join(names) +def load_tags_batch(session: Session, book_ids: list[int]) -> dict[int, str | None]: + """Batch-load comma-separated tag strings for all given book IDs. + + Returns a dict mapping each book_id to its tags string (or None). + """ + if not book_ids: + return {} + rows = session.exec( + select(BookTag.book_id, Tag.name) + .join(Tag, Tag.id == BookTag.tag_id) + .where(BookTag.book_id.in_(book_ids)) + .order_by(BookTag.book_id, Tag.name.asc()) + ).all() + result: dict[int, list[str]] = {} + for book_id, tag_name in rows: + result.setdefault(book_id, []).append(tag_name) + return {bid: ", ".join(names) if names else None for bid, names in result.items()} + + def build_book_read(session: Session, book: Book) -> BookRead: """Build a BookRead response schema from a Book model, populating tags.""" payload = book.model_dump() diff --git a/backend/app/services/transform_engine.py b/backend/app/services/transform_engine.py index 41c3224..f24a89d 100644 --- a/backend/app/services/transform_engine.py +++ b/backend/app/services/transform_engine.py @@ -9,6 +9,7 @@ from typing import Any, Callable from RestrictedPython import compile_restricted +from RestrictedPython.Eval import default_guarded_getitem from RestrictedPython.Guards import safe_builtins, safer_getattr @@ -18,6 +19,7 @@ class TransformExecutionError(Exception): # Whitelisted modules available in transform globals _TRANSFORM_MODULES: dict[str, Any] = { "datetime": __import__("datetime"), + "time": __import__("time"), "re": __import__("re"), "json": __import__("json"), "math": __import__("math"), @@ -41,6 +43,7 @@ def _guarded_import(name: str, *args: Any, **kwargs: Any) -> Any: _TRANSFORM_GLOBALS: dict[str, Any] = { "__builtins__": _CUSTOM_BUILTINS, "_getattr_": safer_getattr, + "_getitem_": default_guarded_getitem, "_getiter_": iter, "_iter_unpack_sequence_": lambda x, y: x, **_TRANSFORM_MODULES, @@ -71,7 +74,7 @@ def _guarded_import(name: str, *args: Any, **kwargs: Any) -> Any: } # Whitelisted modules that may be imported -_ALLOWED_IMPORTS: set[str] = {"datetime", "re", "json", "math"} +_ALLOWED_IMPORTS: set[str] = {"datetime", "time", "re", "json", "math"} # AST node types that are forbidden in transforms _FORBIDDEN_AST_NODES: tuple[type[ast.AST], ...] = ( diff --git a/backend/tests/test_backup_restore.py b/backend/tests/test_backup_restore.py index 2d1433d..4587ec4 100644 --- a/backend/tests/test_backup_restore.py +++ b/backend/tests/test_backup_restore.py @@ -546,14 +546,13 @@ def test_restore_backup_single_file_too_large(valid_backup_zip: bytes, tmp_db_pa # ── _recreate_engine ────────────────────────────────────────────────────────── -def test_recreate_engine(monkeypatch: MonkeyPatch) -> None: +def test_recreate_engine(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: """_recreate_engine should replace app.database.engine.""" import app.database as db_mod original_engine = db_mod.engine + tmp_db = str(tmp_path / "test.db") try: - # Use a temp DB path - tmp_db = "/tmp/test_recreate_engine.db" conn = sqlite3.connect(tmp_db) conn.execute("CREATE TABLE IF NOT EXISTS t (id INT)") conn.commit() @@ -563,10 +562,7 @@ def test_recreate_engine(monkeypatch: MonkeyPatch) -> None: br._recreate_engine() new_engine = db_mod.engine assert new_engine is not original_engine - - os.remove(tmp_db) finally: - # Dispose the newly created engine before restoring the original if db_mod.engine is not original_engine: db_mod.engine.dispose() db_mod.engine = original_engine diff --git a/backend/tests/test_books.py b/backend/tests/test_books.py index fbb3bc8..776704a 100644 --- a/backend/tests/test_books.py +++ b/backend/tests/test_books.py @@ -10,7 +10,7 @@ from sqlmodel import Session from app.config import settings -from app.models import User +from app.models import Book, User import app.routers.books as books_router @@ -252,6 +252,70 @@ def test_list_books_supports_limit_and_offset(client: TestClient) -> None: assert [item["title"] for item in second_body["books"]] == ["Third"] +async def _fake_download_cover(url: str, covers_dir: str | Path, http_client: Any, user_id: int) -> str: + filename = "test_cover.jpg" + (Path(covers_dir) / filename).write_bytes(b"img") + return filename + + +def test_list_books_filter_has_cover_excludes_empty_string(client: TestClient, session: Session, monkeypatch: MonkeyPatch) -> None: + """A book with cover_url='' (bypassing validator) is treated as missing a cover.""" + monkeypatch.setattr(books_router, "import_cover_from_url", _fake_download_cover) + book = _create_book(client, title="Empty Cover") + + # Bypass the model validator by setting cover_url to "" via raw SQL + from sqlalchemy import update as sa_update + session.exec(sa_update(Book).where(Book.id == book["id"]).values(cover_url="")) + session.commit() + + _create_book(client, title="Real Cover", cover_url="http://example.com/real.jpg") + + resp = client.get("/api/books?has_cover=true") + assert resp.status_code == 200 + titles = [b["title"] for b in resp.json()["books"]] + assert "Real Cover" in titles + assert "Empty Cover" not in titles + + +def test_list_books_filter_has_cover_false(client: TestClient, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + monkeypatch.setattr(settings, "covers_dir", str(tmp_path)) + monkeypatch.setattr(books_router, "import_cover_from_url", _fake_download_cover) + _create_book(client, title="No Cover", cover_url=None) + _create_book(client, title="With Cover", cover_url="http://example.com/cover.jpg") + + resp = client.get("/api/books?has_cover=false") + assert resp.status_code == 200 + data = resp.json() + titles = [b["title"] for b in data["books"]] + assert "No Cover" in titles + assert "With Cover" not in titles + + +def test_list_books_filter_has_cover_true(client: TestClient, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + monkeypatch.setattr(settings, "covers_dir", str(tmp_path)) + monkeypatch.setattr(books_router, "import_cover_from_url", _fake_download_cover) + _create_book(client, title="No Cover", cover_url=None) + _create_book(client, title="With Cover", cover_url="http://example.com/cover.jpg") + + resp = client.get("/api/books?has_cover=true") + assert resp.status_code == 200 + data = resp.json() + titles = [b["title"] for b in data["books"]] + assert "With Cover" in titles + assert "No Cover" not in titles + + +def test_list_books_without_has_cover_returns_all(client: TestClient, tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + monkeypatch.setattr(settings, "covers_dir", str(tmp_path)) + monkeypatch.setattr(books_router, "import_cover_from_url", _fake_download_cover) + _create_book(client, title="Book A", cover_url=None) + _create_book(client, title="Book B", cover_url="http://example.com/cover.jpg") + + resp = client.get("/api/books") + assert resp.status_code == 200 + assert len(resp.json()["books"]) == 2 + + def test_update_book_to_did_not_finish_status(client: TestClient) -> None: book = _create_book(client, title="Update To DNF") diff --git a/backend/tests/test_data.py b/backend/tests/test_data.py index e574736..69a5dba 100644 --- a/backend/tests/test_data.py +++ b/backend/tests/test_data.py @@ -385,7 +385,7 @@ def test_data_import_execute_progress_uses_date_finished_for_read_books( assert progress[0]["created_at"] == "2024-01-15T10:30:00Z" -def test_data_import_execute_progress_falls_back_to_now_without_date_finished( +def test_data_import_execute_read_book_without_date_finished_skips_progress( client: TestClient, monkeypatch: MonkeyPatch, tmp_path: Path ) -> None: monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path / "import_temp")) @@ -396,7 +396,6 @@ def test_data_import_execute_progress_falls_back_to_now_without_date_finished( ) file_id = parse_resp.json()["file_id"] - before = datetime.now(timezone.utc) execute_resp = client.post( "/api/data/import/execute", json={ @@ -410,23 +409,16 @@ def test_data_import_execute_progress_falls_back_to_now_without_date_finished( "create_progress_for_read": True, }, ) - after = datetime.now(timezone.utc) assert execute_resp.status_code == 200 + events = _parse_sse(execute_resp.text) + complete = next(e for e in events if e.get("event") == "complete") + assert complete["failed"] == 1 + assert complete["imported"] == 0 books_resp = client.get("/api/books") assert books_resp.status_code == 200 books_body = books_resp.json() - assert books_body["total"] == 1 - book = books_body["books"][0] - assert book["date_finished"] is None - - progress_resp = client.get(f"/api/books/{book['id']}/progress") - assert progress_resp.status_code == 200 - progress = progress_resp.json() - assert len(progress) == 1 - - created_at = datetime.fromisoformat(progress[0]["created_at"].replace("Z", "+00:00")) - assert before <= created_at <= after + assert books_body["total"] == 0 def test_data_export_no_datasets_raises_400(client: TestClient) -> None: diff --git a/backend/tests/test_data_import.py b/backend/tests/test_data_import.py index 9e3dfd4..5694c19 100644 --- a/backend/tests/test_data_import.py +++ b/backend/tests/test_data_import.py @@ -738,8 +738,8 @@ async def test_execute_import_progress_naive_utcnow_fallback(session: Session, t monkeypatch.setattr(settings, "import_temp_dir", str(tmp_path)) user = _create_test_user(session) payload = { - "rows": [{"title": "Book", "status": "read", "pages": "100"}], - "source_fields": ["title", "status", "pages"], + "rows": [{"title": "Book", "status": "read", "pages": "100", "finished": "2024-01-15"}], + "source_fields": ["title", "status", "pages", "finished"], } file_id = "test_exec_naive_utc" path = di._temp_file_path(user.id, file_id) @@ -753,7 +753,7 @@ async def test_execute_import_progress_naive_utcnow_fallback(session: Session, t async for event in di.execute_import( file_id, user, - {"title": ImportFieldConfig(source="title"), "reading_status": ImportFieldConfig(source="status"), "page_count": ImportFieldConfig(source="pages")}, + {"title": ImportFieldConfig(source="title"), "reading_status": ImportFieldConfig(source="status"), "page_count": ImportFieldConfig(source="pages"), "date_finished": ImportFieldConfig(source="finished")}, session, "continue_on_error", create_progress_for_read=True, ): diff --git a/backend/tests/test_statistics.py b/backend/tests/test_statistics.py index dfc39a0..ae063a1 100644 --- a/backend/tests/test_statistics.py +++ b/backend/tests/test_statistics.py @@ -151,7 +151,8 @@ def test_statistics_top_authors_no_covers(client: Any) -> None: assert resp.status_code == 200 top_authors = resp.json()["top_authors"] assert top_authors[0]["author"] == "No Cover Author" - assert top_authors[0]["covers"] == [] + assert len(top_authors[0]["covers"]) == 2 + assert all(c["cover_url"] is None for c in top_authors[0]["covers"]) def test_statistics_timezone_month_bucketing(client: Any, session: Session) -> None: diff --git a/cli/src/llc/docker.py b/cli/src/llc/docker.py new file mode 100644 index 0000000..162fdba --- /dev/null +++ b/cli/src/llc/docker.py @@ -0,0 +1,74 @@ +import subprocess +from enum import Enum +from pathlib import Path + +import typer +from llc._interactive import console + +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +_DEV_COMPOSE = _PROJECT_ROOT / "docker-compose.dev.yml" +_PROD_COMPOSE = _PROJECT_ROOT / "docker-compose.yml" +_E2E_COMPOSE = _PROJECT_ROOT / "docker-compose.e2e.yml" + + +class ComposeEnv(str, Enum): + dev = "dev" + prod = "prod" + e2e = "e2e" + + +def _resolve_compose(env: ComposeEnv) -> Path: + return {"dev": _DEV_COMPOSE, "prod": _PROD_COMPOSE, "e2e": _E2E_COMPOSE}[env.value] + + +def _compose_cmd(compose_file: Path, args: list[str]) -> list[str]: + return ["docker", "compose", "-f", str(compose_file), *args] + + +def cmd_up(service: str | None = None, *, env: ComposeEnv = ComposeEnv.dev) -> None: + cmd = ["up", "--build", "-d"] + if service: + cmd.append(service) + label = f" [cyan]{service}[/cyan]" if service else "" + console.print(f"[bold]Building and starting{label}...[/bold]") + code = subprocess.call(_compose_cmd(_resolve_compose(env), cmd)) + if code != 0: + raise typer.Exit(code=code) + + +def cmd_down(*, env: ComposeEnv = ComposeEnv.dev) -> None: + console.print("[bold]Stopping containers...[/bold]") + code = subprocess.call(_compose_cmd(_resolve_compose(env), ["down"])) + if code != 0: + raise typer.Exit(code=code) + + +def cmd_logs(follow: bool = False, service: str | None = None, *, env: ComposeEnv = ComposeEnv.dev) -> None: + cmd = ["logs"] + if follow: + cmd.append("-f") + if service: + cmd.append(service) + code = subprocess.call(_compose_cmd(_resolve_compose(env), cmd)) + if code != 0: + raise typer.Exit(code=code) + + +def cmd_status(*, env: ComposeEnv = ComposeEnv.dev) -> None: + code = subprocess.call(_compose_cmd(_resolve_compose(env), ["ps"])) + if code != 0: + raise typer.Exit(code=code) + + +def cmd_shell(service: str) -> None: + console.print(f"[bold]Opening shell in [cyan]{service}[/cyan]...[/bold]") + code = subprocess.call(_compose_cmd(_DEV_COMPOSE, ["exec", service, "sh"])) + if code != 0: + raise typer.Exit(code=code) + + +def cmd_restart(service: str, *, env: ComposeEnv = ComposeEnv.dev) -> None: + console.print(f"[bold]Restarting [cyan]{service}[/cyan]...[/bold]") + code = subprocess.call(_compose_cmd(_resolve_compose(env), ["restart", service])) + if code != 0: + raise typer.Exit(code=code) diff --git a/cli/src/llc/main.py b/cli/src/llc/main.py index 243a2ce..4b2dd05 100644 --- a/cli/src/llc/main.py +++ b/cli/src/llc/main.py @@ -1,4 +1,5 @@ import typer +from llc.docker import ComposeEnv app = typer.Typer( name="ll", @@ -32,11 +33,17 @@ help="Build and serve documentation", rich_markup_mode="rich", ) +docker_app = typer.Typer( + name="docker", + help="Manage Docker containers (up, down, logs, status, shell, restart)", + rich_markup_mode="rich", +) app.add_typer(pr_app) app.add_typer(tag_app) app.add_typer(test_app) app.add_typer(branch_app) app.add_typer(docs_app) +app.add_typer(docker_app) @pr_app.command("list") @@ -151,3 +158,61 @@ def docs_preview(): """Preview the built VitePress documentation site.""" from llc.docs import cmd_preview cmd_preview() + + +@docker_app.command("up") +def docker_up( + service: str | None = typer.Argument(None, help="Service to build and start (default: all)"), + env: ComposeEnv = typer.Option(ComposeEnv.dev, "--env", help="Compose environment to target [dev|prod|e2e]"), +): + """Build and start containers (optionally a single service).""" + from llc.docker import cmd_up + cmd_up(service, env=env) + + +@docker_app.command("down") +def docker_down( + env: ComposeEnv = typer.Option(ComposeEnv.dev, "--env", help="Compose environment to target [dev|prod|e2e]"), +): + """Stop and remove containers.""" + from llc.docker import cmd_down + cmd_down(env=env) + + +@docker_app.command("logs") +def docker_logs( + follow: bool = typer.Option(False, "--follow", "-f", help="Follow log output"), + service: str | None = typer.Argument(None, help="Service name"), + env: ComposeEnv = typer.Option(ComposeEnv.dev, "--env", help="Compose environment to target [dev|prod|e2e]"), +): + """View container logs.""" + from llc.docker import cmd_logs + cmd_logs(follow=follow, service=service, env=env) + + +@docker_app.command("status") +def docker_status( + env: ComposeEnv = typer.Option(ComposeEnv.dev, "--env", help="Compose environment to target [dev|prod|e2e]"), +): + """Show container status.""" + from llc.docker import cmd_status + cmd_status(env=env) + + +@docker_app.command("shell") +def docker_shell( + service: str = typer.Argument(..., help="Service name (e.g. backend, frontend)"), +): + """Open a shell in a running container.""" + from llc.docker import cmd_shell + cmd_shell(service) + + +@docker_app.command("restart") +def docker_restart( + service: str = typer.Argument(..., help="Service name"), + env: ComposeEnv = typer.Option(ComposeEnv.dev, "--env", help="Compose environment to target [dev|prod|e2e]"), +): + """Restart a service.""" + from llc.docker import cmd_restart + cmd_restart(service, env=env) diff --git a/docs/.vitepress/config.base.ts b/docs/.vitepress/config.base.ts index 3a60375..2b4646f 100644 --- a/docs/.vitepress/config.base.ts +++ b/docs/.vitepress/config.base.ts @@ -1,8 +1,15 @@ import { defineConfig } from 'vitepress' +import { execSync } from 'child_process' + +const gitSha = execSync('git rev-parse HEAD').toString().trim() export default defineConfig({ title: 'LibrisLog', vite: { + define: { + __GIT_SHA__: JSON.stringify(gitSha), + __GIT_SHA_SHORT__: JSON.stringify(gitSha.slice(0, 7)), + }, server: { host: true, port: 5174, @@ -57,6 +64,7 @@ export default defineConfig({ { text: 'Statistics', link: '/guide/using-librislog/statistics' }, { text: 'Import & Export', link: '/guide/using-librislog/import-export' }, { text: 'Data Hygiene', link: '/guide/using-librislog/data-hygiene' }, + { text: 'Missing Covers', link: '/guide/using-librislog/missing-covers' }, { text: 'Administration', link: '/guide/using-librislog/administration' }, ], }, diff --git a/docs/.vitepress/theme/components/CommitInfo.vue b/docs/.vitepress/theme/components/CommitInfo.vue new file mode 100644 index 0000000..6ab0b12 --- /dev/null +++ b/docs/.vitepress/theme/components/CommitInfo.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/docs/.vitepress/theme/env.d.ts b/docs/.vitepress/theme/env.d.ts new file mode 100644 index 0000000..b4fcf15 --- /dev/null +++ b/docs/.vitepress/theme/env.d.ts @@ -0,0 +1,2 @@ +declare const __GIT_SHA__: string +declare const __GIT_SHA_SHORT__: string diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index b5248c7..b4c603f 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -3,12 +3,14 @@ import DefaultTheme from 'vitepress/theme' import { useRoute } from 'vitepress' import imageViewer from 'vitepress-plugin-image-viewer' import vImageViewer from 'vitepress-plugin-image-viewer/lib/vImageViewer.vue' +import CommitInfo from './components/CommitInfo.vue' import 'viewerjs/dist/viewer.min.css' export default { extends: DefaultTheme, enhanceApp({ app }) { app.component('vImageViewer', vImageViewer) + app.component('CommitInfo', CommitInfo) }, setup() { const route = useRoute() diff --git a/docs/about.md b/docs/about.md index 5eabd48..91d168f 100644 --- a/docs/about.md +++ b/docs/about.md @@ -38,4 +38,6 @@ Created and maintained by [Raffael Herrmann](https://github.com/codebude). ## License -Released under the MIT License. \ No newline at end of file +Released under the MIT License. + + diff --git a/docs/guide/cli.md b/docs/guide/cli.md index c7ed514..b931f05 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -66,6 +66,22 @@ Create and delete semantic version tags. | `llc tag create` | Interactive tag creation — picks the branch, suggests the next version (major/minor/patch bump), creates and pushes the tag | | `llc tag delete` | Interactive tag deletion — select from recent tags or enter a name, deletes locally and remotely | +### `llc docker` + +Manage Docker containers using compose files. + +All commands accept `--env [dev|prod|e2e]` to select the compose file +(default: `dev`, which uses `docker-compose.dev.yml`). + +| Command | Description | +|---------|-------------| +| `llc docker up [service]` | Build and start containers (all, or a single service) | +| `llc docker down` | Stop and remove containers | +| `llc docker logs [-f] [service]` | View container logs | +| `llc docker status` | Show container status | +| `llc docker shell ` | Open a shell in a running container | +| `llc docker restart ` | Restart a service | + ### `llc branch` Create, delete, and sync local branches. diff --git a/docs/guide/using-librislog/administration.md b/docs/guide/using-librislog/administration.md index 7101fac..d743de3 100644 --- a/docs/guide/using-librislog/administration.md +++ b/docs/guide/using-librislog/administration.md @@ -25,11 +25,11 @@ Click "Delete" to remove a user account. You cannot delete your own account from ### Creating a Backup -Downloads the entire SQLite database as a `.db` file. This is a complete snapshot of your library, users, and settings. +Downloads a ZIP archive containing the SQLite database, cover images, and import temp files. This is a complete snapshot of your library, users, and settings. ### Restoring a Backup -Upload a previously downloaded `.db` file to restore the database. The app validates the backup before applying it. +Upload a previously downloaded `.zip` backup file to restore the database. The app validates the backup before applying it. Backups from older versions are automatically migrated to the current schema on restore. ::: warning Restoring overwrites all current data. Create a fresh backup first if you want to preserve your current library. diff --git a/docs/guide/using-librislog/data-hygiene.md b/docs/guide/using-librislog/data-hygiene.md index f972fc3..44b9ef3 100644 --- a/docs/guide/using-librislog/data-hygiene.md +++ b/docs/guide/using-librislog/data-hygiene.md @@ -48,3 +48,7 @@ No lower bound is enforced, so ancient or religious texts (e.g., the Bible) can - **All complete**: When no books are missing data, a green success message appears - **Filtered complete**: When specific attributes are selected and all books have them, a tailored success message is shown - **Errors**: API errors are displayed in an alert banner; dismiss it to try again + +## See Also + +- [Missing Covers Workflow](/guide/using-librislog/missing-covers) — dedicated one-at-a-time workflow for assigning book covers diff --git a/docs/guide/using-librislog/missing-covers.md b/docs/guide/using-librislog/missing-covers.md new file mode 100644 index 0000000..999523f --- /dev/null +++ b/docs/guide/using-librislog/missing-covers.md @@ -0,0 +1,46 @@ +# Missing Covers Workflow + +The Missing Covers page provides a streamlined, one-book-at-a-time workflow for assigning book covers to books missing them. It's accessible from **Profile → Manage my data → Manage Missing Covers**. + +![Missing Covers workflow](/screenshots/missing-covers.png) + +## Workflow + +The page shows one book at a time along with auto-searched cover candidates and manual options. + +### Cover Candidates + +If the book has an ISBN, the page automatically searches multiple cover sources (AbeBooks, Open Library, Amazon, Hardcover) and displays results in a resolution-sorted grid. Click a candidate to save it as the book's cover — the app downloads the image, stores it locally, and immediately advances to the next book. + +### No ISBN or No Candidates + +If the book has no ISBN or no cover candidates were found, the page shows: + +- **Search Cover on Google** — opens a Google image search for the book in a new tab +- **Manual URL input** — paste a direct image URL and click **Save Cover** + +### Skip + +Click **Skip** to move past the current book without saving a cover. Skipped books remain in the missing-covers count. + +### Keyboard Shortcuts + +| Key | Action | +|-----|--------| +| `1`–`9` | Select the N-th cover candidate | +| `→` (Arrow Right) | Skip current book | + +## All Done + +When all missing covers have been assigned, a success message is shown with a link back to the library. + +## Difference from Data Hygiene + +| Aspect | Data Hygiene | Missing Covers | +|--------|--------------|----------------| +| **Scope** | Any missing metadata (author, ISBN, cover, etc.) | Covers only | +| **Interaction model** | Table with checkboxes, batch updates | One book at a time, immediate save | +| **Auto-suggestions** | None | Auto-searches cover candidates by ISBN | +| **Speed optimization** | Paginated list (50/page) | Prefetch next book + candidates; keyboard shortcuts | +| **Entry point** | Profile → Data Hygiene | Profile → Manage Missing Covers | +| **Success feedback** | Batch toast | Per-book toast + auto-advance | diff --git a/docs/public/screenshots/admin-backup-thumb.png b/docs/public/screenshots/admin-backup-thumb.png index 424ac25..bbab7a0 100644 Binary files a/docs/public/screenshots/admin-backup-thumb.png and b/docs/public/screenshots/admin-backup-thumb.png differ diff --git a/docs/public/screenshots/admin-backup.png b/docs/public/screenshots/admin-backup.png index 53e5928..d06b59a 100644 Binary files a/docs/public/screenshots/admin-backup.png and b/docs/public/screenshots/admin-backup.png differ diff --git a/docs/public/screenshots/admin-users-thumb.png b/docs/public/screenshots/admin-users-thumb.png index a52c82a..4829c43 100644 Binary files a/docs/public/screenshots/admin-users-thumb.png and b/docs/public/screenshots/admin-users-thumb.png differ diff --git a/docs/public/screenshots/admin-users.png b/docs/public/screenshots/admin-users.png index 4e484ed..701de67 100644 Binary files a/docs/public/screenshots/admin-users.png and b/docs/public/screenshots/admin-users.png differ diff --git a/docs/public/screenshots/dashboard-thumb.png b/docs/public/screenshots/dashboard-thumb.png index be58afa..c2b7b02 100644 Binary files a/docs/public/screenshots/dashboard-thumb.png and b/docs/public/screenshots/dashboard-thumb.png differ diff --git a/docs/public/screenshots/dashboard.png b/docs/public/screenshots/dashboard.png index 34f5d5d..c8d378f 100644 Binary files a/docs/public/screenshots/dashboard.png and b/docs/public/screenshots/dashboard.png differ diff --git a/docs/public/screenshots/data-import-thumb.png b/docs/public/screenshots/data-import-thumb.png index 82829fb..d830392 100644 Binary files a/docs/public/screenshots/data-import-thumb.png and b/docs/public/screenshots/data-import-thumb.png differ diff --git a/docs/public/screenshots/data-import.png b/docs/public/screenshots/data-import.png index 8bece01..25502bd 100644 Binary files a/docs/public/screenshots/data-import.png and b/docs/public/screenshots/data-import.png differ diff --git a/docs/public/screenshots/library-read-thumb.png b/docs/public/screenshots/library-read-thumb.png index 1d30c25..a775268 100644 Binary files a/docs/public/screenshots/library-read-thumb.png and b/docs/public/screenshots/library-read-thumb.png differ diff --git a/docs/public/screenshots/library-read.png b/docs/public/screenshots/library-read.png index c7d88b3..59dbe02 100644 Binary files a/docs/public/screenshots/library-read.png and b/docs/public/screenshots/library-read.png differ diff --git a/docs/public/screenshots/missing-covers.png b/docs/public/screenshots/missing-covers.png new file mode 100644 index 0000000..a6ca325 Binary files /dev/null and b/docs/public/screenshots/missing-covers.png differ diff --git a/docs/public/screenshots/profile-api-keys-thumb.png b/docs/public/screenshots/profile-api-keys-thumb.png index 0c7ce3f..2e69b8c 100644 Binary files a/docs/public/screenshots/profile-api-keys-thumb.png and b/docs/public/screenshots/profile-api-keys-thumb.png differ diff --git a/docs/public/screenshots/profile-api-keys.png b/docs/public/screenshots/profile-api-keys.png index 6817eb0..5709464 100644 Binary files a/docs/public/screenshots/profile-api-keys.png and b/docs/public/screenshots/profile-api-keys.png differ diff --git a/docs/public/screenshots/profile-thumb.png b/docs/public/screenshots/profile-thumb.png index 4633843..a73336e 100644 Binary files a/docs/public/screenshots/profile-thumb.png and b/docs/public/screenshots/profile-thumb.png differ diff --git a/docs/public/screenshots/profile.png b/docs/public/screenshots/profile.png index cb2d94f..2432539 100644 Binary files a/docs/public/screenshots/profile.png and b/docs/public/screenshots/profile.png differ diff --git a/docs/public/screenshots/statistics-thumb.png b/docs/public/screenshots/statistics-thumb.png index a2f4eea..33743dd 100644 Binary files a/docs/public/screenshots/statistics-thumb.png and b/docs/public/screenshots/statistics-thumb.png differ diff --git a/docs/public/screenshots/statistics.png b/docs/public/screenshots/statistics.png index d4355b4..33d1838 100644 Binary files a/docs/public/screenshots/statistics.png and b/docs/public/screenshots/statistics.png differ diff --git a/frontend/e2e/fixtures/pages/library.page.ts b/frontend/e2e/fixtures/pages/library.page.ts index 8f756cb..7260231 100644 --- a/frontend/e2e/fixtures/pages/library.page.ts +++ b/frontend/e2e/fixtures/pages/library.page.ts @@ -9,7 +9,24 @@ export class LibraryPage { } async switchTab(status: string) { - const tab = this.page.locator(`[role="tab"]`).filter({ hasText: new RegExp(status, 'i') }); + const STATUS_ORDER: Record = { + 'want to read': 0, + 'currently reading': 1, + 'read': 2, + 'did not finish': 3, + }; + const key = status.toLowerCase().replace(/\s+/g, '_'); + const posMap: Record = { + 'want_to_read': 0, + 'currently_reading': 1, + 'read': 2, + 'did_not_finish': 3, + }; + const index = posMap[key] ?? STATUS_ORDER[status.toLowerCase()]; + if (index === undefined) { + throw new Error(`Unknown tab status: ${status}`); + } + const tab = this.page.locator('[role="tab"]').nth(index); await tab.click(); await this.page.waitForTimeout(500); } diff --git a/frontend/e2e/fixtures/pages/missing-covers.page.ts b/frontend/e2e/fixtures/pages/missing-covers.page.ts new file mode 100644 index 0000000..35b6185 --- /dev/null +++ b/frontend/e2e/fixtures/pages/missing-covers.page.ts @@ -0,0 +1,72 @@ +import type { Page, Locator } from '@playwright/test'; + +export class MissingCoversPage { + constructor(private page: Page) {} + + async goto() { + await this.page.goto('/missing-covers'); + await this.page.waitForSelector('h1'); + } + + async getHeader(): Promise { + return this.page.locator('h1').textContent(); + } + + async getCurrentBookTitle(): Promise { + const el = this.page.locator('.text-lg.font-semibold').first(); + return el.textContent(); + } + + async getCurrentBookAuthor(): Promise { + const el = this.page.locator('.text-sm.text-base-content\\/70').first(); + return el.textContent(); + } + + async getCandidateGrid(): Promise { + return this.page.locator('.grid.grid-cols-2'); + } + + async getCandidateButtons() { + return this.page.locator('.grid.grid-cols-2 button[type="button"]'); + } + + async selectCandidate(index: number) { + const btns = this.getCandidateButtons(); + await btns.nth(index).click(); + } + + async clickSkip() { + await this.page.locator('button[aria-label="Skip this book and go to the next"]').click(); + } + + async clickSaveManualUrl() { + await this.page.locator('button').filter({ hasText: 'Save Cover' }).click(); + } + + async fillManualUrl(url: string) { + await this.page.locator('input[type="url"]').fill(url); + } + + async getManualUrlInput(): Promise { + return this.page.locator('input[type="url"]'); + } + + async clickGoogleSearchLink() { + await this.page.locator('a[aria-label="Open Google image search for this book in a new tab"]').click(); + } + + async getBackLink(): Promise { + return this.page.locator('a').filter({ hasText: 'Back' }); + } + + async getKeyboardHint(): Promise { + return this.page.locator('text="Tip:"').textContent(); + } + + async waitForCandidates() { + await this.page.waitForFunction(() => { + const grid = document.querySelector('.grid.grid-cols-2'); + return grid && grid.querySelectorAll('button[type="button"]').length > 0; + }, { timeout: 15000 }); + } +} diff --git a/frontend/e2e/specs/05-edit-book.spec.ts b/frontend/e2e/specs/05-edit-book.spec.ts index b2395dc..e9a4628 100644 --- a/frontend/e2e/specs/05-edit-book.spec.ts +++ b/frontend/e2e/specs/05-edit-book.spec.ts @@ -58,4 +58,34 @@ test.describe('Edit Book', () => { } await page.waitForTimeout(500); }); + + test('5.3 set and persist rating from detail dialog', 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 }); + await cards.first().click(); + await page.waitForSelector('[role="dialog"]', { timeout: 5000 }); + + const star2 = page.locator('[role="dialog"] input[type="radio"][aria-label*="2"]'); + await expect(star2).toBeVisible({ timeout: 5000 }); + await star2.click(); + + await expect(page.getByText(/Rating saved|Bewertung gespeichert/i)).toBeVisible({ timeout: 3000 }); + + const closeBtn = page.locator('button[aria-label*="Close"], button[aria-label*="Schließen"]').first(); + await closeBtn.click(); + await page.waitForTimeout(500); + + await cards.first().click(); + await page.waitForSelector('[role="dialog"]', { timeout: 5000 }); + + const checkedStar = page.locator('[role="dialog"] input[type="radio"]:checked'); + await expect(checkedStar).toHaveAttribute('aria-label', /2 (star|Stern)/); + }); }); diff --git a/frontend/e2e/specs/08-statistics.spec.ts b/frontend/e2e/specs/08-statistics.spec.ts index 591c2d0..225aeb7 100644 --- a/frontend/e2e/specs/08-statistics.spec.ts +++ b/frontend/e2e/specs/08-statistics.spec.ts @@ -16,4 +16,21 @@ test.describe('Statistics', () => { const body = page.locator('body'); await expect(body).toContainText(/total|books|pages|rating|read/i); }); + + test('8.2 rating statistics are displayed', async ({ page }) => { + await page.goto('/statistics'); + await page.waitForTimeout(2000); + + await expect(page.getByText(/Books with Rating|Bewertete Bücher/)).toBeVisible(); + await expect(page.getByText(/Books without Rating|Unbewertete Bücher/)).toBeVisible(); + await expect(page.getByText(/Avg Rating|Ø Bewertung/)).toBeVisible(); + + await expect(page.getByText(/Top Rated|Am besten bewertet/)).toBeVisible(); + await expect(page.getByText(/Worst Rated|Am schlechtesten bewertet/)).toBeVisible(); + + await expect(page.getByText('To Kill a Mockingbird').first()).toBeVisible(); + await expect(page.getByText('1984').first()).toBeVisible(); + await expect(page.getByText('The Great Gatsby').first()).toBeVisible(); + await expect(page.getByText('Brave New World').first()).toBeVisible(); + }); }); diff --git a/frontend/e2e/specs/14-missing-covers.spec.ts b/frontend/e2e/specs/14-missing-covers.spec.ts new file mode 100644 index 0000000..e7f8683 --- /dev/null +++ b/frontend/e2e/specs/14-missing-covers.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from '@playwright/test'; +import { loginViaUi } from '../fixtures/auth.fixture'; +import { seedBooks, deleteAllBooks } from '../fixtures/seed.api'; +import { SEED_USER } from '../fixtures/seed-data'; +import { MissingCoversPage } from '../fixtures/pages/missing-covers.page'; + +test.describe('Missing Covers Workflow', () => { + test.beforeEach(async ({ page }) => { + await loginViaUi(page, SEED_USER.email, SEED_USER.password); + await deleteAllBooks(page); + }); + + test('1. Entry point from profile navigates to missing-covers', async ({ page }) => { + await page.goto('/profile'); + await page.waitForTimeout(1000); + await page.locator('a[href="/missing-covers"]').first().click(); + await expect(page).toHaveURL(/\/missing-covers/); + }); + + test('2. Header shows correct count', async ({ page }) => { + await seedBooks(page, [ + { title: 'Book A', author: 'Author A', page_count: 100, reading_status: 'read' }, + { title: 'Book B', author: 'Author B', page_count: 200, reading_status: 'read' }, + { title: 'Book C', author: 'Author C', page_count: 300, reading_status: 'read' }, + ]); + const missing = new MissingCoversPage(page); + await missing.goto(); + await page.waitForTimeout(2000); + const header = await missing.getHeader(); + expect(header).toContain('3'); + }); + + test('3. Displays current book info', async ({ page }) => { + await seedBooks(page, [ + { title: 'Visible Book', author: 'Test Author', page_count: 100, reading_status: 'read' }, + ]); + const missing = new MissingCoversPage(page); + await missing.goto(); + await page.waitForTimeout(2000); + const title = await missing.getCurrentBookTitle(); + expect(title).toBe('Visible Book'); + }); + + test('4. Missing covers page loads with books', async ({ page }) => { + const missing = new MissingCoversPage(page); + await missing.goto(); + await page.waitForTimeout(3000); + expect(await page.locator('h1').isVisible()).toBe(true); + }); + + test('5. No-ISBN state shows manual fallback and google link', async ({ page }) => { + await seedBooks(page, [ + { title: 'No ISBN', author: 'Author X', page_count: 100, reading_status: 'read' }, + ]); + const missing = new MissingCoversPage(page); + await missing.goto(); + await page.waitForTimeout(3000); + + await expect(page.locator('input[type="url"]')).toBeVisible({ timeout: 10000 }); + }); + + test('6. Back link navigates to profile', async ({ page }) => { + const missing = new MissingCoversPage(page); + await missing.goto(); + await page.waitForTimeout(3000); + + await page.locator('a[href="/profile"]').first().click(); + await expect(page).toHaveURL(/\/profile/); + }); +}); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4973fdb..be93ad1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -279,6 +279,7 @@ export const api = { list(params?: { status?: ReadingStatus; q?: string; + has_cover?: boolean; sort?: SortField; order?: SortOrder; smart_sort?: boolean; @@ -288,6 +289,7 @@ export const api = { const qs = new URLSearchParams(); if (params?.status) qs.set('status', params.status); if (params?.q) qs.set('q', params.q); + if (params?.has_cover !== undefined) qs.set('has_cover', String(params.has_cover)); if (params?.sort) qs.set('sort', params.sort); if (params?.order) qs.set('order', params.order); if (params?.smart_sort !== undefined) qs.set('smart_sort', String(params.smart_sort)); diff --git a/frontend/src/lib/components/AutoSearchCoverModal.svelte b/frontend/src/lib/components/AutoSearchCoverModal.svelte index f6a67ef..e7722e7 100644 --- a/frontend/src/lib/components/AutoSearchCoverModal.svelte +++ b/frontend/src/lib/components/AutoSearchCoverModal.svelte @@ -1,8 +1,8 @@ {#if open} @@ -82,46 +39,9 @@ {$_('book.autoSearchLoading')} {:else} - {#if error} - - {error} - - {/if} -

{$_('book.autoSearchInfo')}

- {#if sorted.length === 0} -
{$_('book.autoSearchNoCandidates')}
- {:else} -
- {#each sorted as candidate (resolutionKey(candidate))} - - {/each} -
- {/if} + {/if}