diff --git a/README.md b/README.md index 384d042..622d368 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@
{$_('book.autoSearchInfo')} {displayVersion}
@@ -96,6 +89,33 @@
{$_('dataHygiene.description')} {$_('dataHygiene.description')} {$_('dataHygiene.loading')}
- {$_('dataHygiene.noMissingBooks')}
- {$_('dataHygiene.noMissingBooks')} {$_('missingCovers.allDoneSub')} {currentBook.title} {currentBook.author}
+ {currentBook.isbn ? $_('missingCovers.isbnLabel', { values: { isbn: currentBook.isbn } }) : $_('missingCovers.noIsbn')}
+ {$_('missingCovers.keyboardHint')} {$_('profile.dataManagement.description')} {$_('dataHygiene.description')} {$_('profile.dataManagement.description')} {$_('dataHygiene.description')} {$_('profile.dataManagement.missingCoversDescription')} {hoveredCover?.title ?? ''}
-
+
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 @@
+
+ Built from commit:
{{ sha }}
+
+
+
+
+
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.
+
+{$_('about.title')}
LibrisLog
- {$_('about.technologies')}
diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte
index 974131d..4b79e87 100644
--- a/frontend/src/routes/admin/+page.svelte
+++ b/frontend/src/routes/admin/+page.svelte
@@ -146,11 +146,11 @@
{#if !isAdmin}
- {$_('admin.title')}
{$_('dataHygiene.title')}
- {$_('dataHygiene.title')}
+ {$_('dataHygiene.sectionFilters')}
+
-
-
-
-
-
-
- {#each books as book (book.id)}
-
- 0}
- onchange={toggleSelectAll}
- aria-label={selectedBookIds.size === books.length ? $_('dataHygiene.deselectAll') : $_('dataHygiene.selectAll')}
- />
-
- {$_('book.title')}
- {$_('book.author')}
- {$_('book.isbn')}
- {$_('book.publisher')}
- {$_('dataHygiene.tableHeaderMissing')}
-
-
- {/each}
-
-
-
- {book.title}
- {book.author || '—'}
- {book.isbn || '—'}
- {book.publisher || '—'}
-
-
-
+
+
+
+
+
+ {#each books as book (book.id)}
+
+ 0}
+ onchange={toggleSelectAll}
+ aria-label={selectedBookIds.size === books.length ? $_('dataHygiene.deselectAll') : $_('dataHygiene.selectAll')}
+ />
+
+ {$_('book.title')}
+ {$_('book.author')}
+ {$_('book.isbn')}
+ {$_('book.publisher')}
+ {$_('dataHygiene.tableHeaderMissing')}
+
+
+ {/each}
+
+
+
+ {book.title}
+ {book.author || '—'}
+ {book.isbn || '—'}
+ {book.publisher || '—'}
+
+
+ {$_('missingCovers.header', { values: { count: displayRemaining } })}
+ {$_('missingCovers.header', { values: { count: displayRemaining } })}
+
+ {#if advancing}
+ {$_('user.profile')}
{$_('profile.dataManagement.title')}
-
{/each}
@@ -452,6 +466,8 @@
{$_('statistics.pagesReadCalendar')}
@@ -486,6 +502,39 @@
{/if}