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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions backend/alembic/versions/7a8b9c0d1e2f_add_medium_to_books.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""add optional medium to books

Revision ID: 7a8b9c0d1e2f
Revises: f3a5b7c9d1e2
Create Date: 2026-09-08 23:40:00.000000

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


revision: str = "7a8b9c0d1e2f"
down_revision: Union[str, Sequence[str], None] = "f3a5b7c9d1e2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
with op.batch_alter_table("book") as batch_op:
batch_op.add_column(sa.Column("medium", sa.String(length=32), nullable=True))
batch_op.create_index("ix_book_medium", ["medium"], unique=False)


def downgrade() -> None:
with op.batch_alter_table("book") as batch_op:
batch_op.drop_index("ix_book_medium")
batch_op.drop_column("medium")
13 changes: 13 additions & 0 deletions backend/app/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@
"avg_pages": "Seiten/Buch"
}
},
"medium": {
"print": "Print",
"ebook": "eBook",
"audiobook": "Hörbuch",
"comic_graphic_novel": "Comic / Graphic Novel",
"magazine_newspaper": "Magazin / Zeitung"
},
"acquisition": {
"owned": "Im Besitz",
"borrowed": "Geliehen",
"digital_access": "Digital verfügbar",
"to_acquire": "Muss noch beschafft werden"
},
"email": {
"passwordResetSubject": "Passwort zurücksetzen – LibrisLog",
"passwordResetBody": "<html>\n<body>\n<p>Du hast das Zurücksetzen deines Passworts für dein LibrisLog-Konto beantragt.</p>\n<p>Klicke auf den Link unten, um dein Passwort zurückzusetzen. Dieser Link ist {duration_minutes} Minuten gültig.</p>\n<p><a href=\"{reset_url}\">{reset_url}</a></p>\n<p>Falls du dies nicht angefordert hast, ignoriere bitte diese E-Mail.</p>\n</body>\n</html>"
Expand Down
13 changes: 13 additions & 0 deletions backend/app/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@
"avg_pages": "Avg/Book"
}
},
"medium": {
"print": "Print",
"ebook": "eBook",
"audiobook": "Audiobook",
"comic_graphic_novel": "Comic / Graphic Novel",
"magazine_newspaper": "Magazine / Newspaper"
},
"acquisition": {
"owned": "Owned",
"borrowed": "Borrowed",
"digital_access": "Digital access",
"to_acquire": "Needs to be acquired"
},
"email": {
"passwordResetSubject": "Password Reset – LibrisLog",
"passwordResetBody": "<html>\n<body>\n<p>You have requested a password reset for your LibrisLog account.</p>\n<p>Click the link below to reset your password. This link is valid for {duration_minutes} minutes.</p>\n<p><a href=\"{reset_url}\">{reset_url}</a></p>\n<p>If you did not request this, please ignore this email.</p>\n</body>\n</html>"
Expand Down
13 changes: 13 additions & 0 deletions backend/app/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@
"avg_pages": "Páginas/Libro"
}
},
"medium": {
"print": "Impreso",
"ebook": "eBook",
"audiobook": "Audiolibro",
"comic_graphic_novel": "Cómic / Novela gráfica",
"magazine_newspaper": "Revista / Periódico"
},
"acquisition": {
"owned": "En propiedad",
"borrowed": "Prestado",
"digital_access": "Acceso digital",
"to_acquire": "Por adquirir"
},
"email": {
"passwordResetSubject": "Restablecer contraseña – LibrisLog",
"passwordResetBody": "<html>\n<body>\n<p>Has solicitado un restablecimiento de contraseña para tu cuenta de LibrisLog.</p>\n<p>Haz clic en el enlace de abajo para restablecer tu contraseña. Este enlace es válido por {duration_minutes} minutos.</p>\n<p><a href=\"{reset_url}\">{reset_url}</a></p>\n<p>Si no solicitaste esto, ignora este correo electrónico.</p>\n</body>\n</html>"
Expand Down
13 changes: 13 additions & 0 deletions backend/app/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@
"avg_pages": "Pages/Livre"
}
},
"medium": {
"print": "Imprimé",
"ebook": "eBook",
"audiobook": "Livre audio",
"comic_graphic_novel": "Bande dessinée / Roman graphique",
"magazine_newspaper": "Magazine / Journal"
},
"acquisition": {
"owned": "Possédé",
"borrowed": "Emprunté",
"digital_access": "Accès numérique",
"to_acquire": "À acquérir"
},
"email": {
"passwordResetSubject": "Réinitialisation du mot de passe – LibrisLog",
"passwordResetBody": "<html>\n<body>\n<p>Vous avez demandé une réinitialisation de mot de passe pour votre compte LibrisLog.</p>\n<p>Cliquez sur le lien ci-dessous pour réinitialiser votre mot de passe. Ce lien est valable {duration_minutes} minutes.</p>\n<p><a href=\"{reset_url}\">{reset_url}</a></p>\n<p>Si vous n'avez pas demandé cela, veuillez ignorer cet e-mail.</p>\n</body>\n</html>"
Expand Down
13 changes: 13 additions & 0 deletions backend/app/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@
"avg_pages": "每本页数"
}
},
"medium": {
"print": "纸质书",
"ebook": "电子书",
"audiobook": "有声书",
"comic_graphic_novel": "漫画 / 图像小说",
"magazine_newspaper": "杂志 / 报纸"
},
"acquisition": {
"owned": "已拥有",
"borrowed": "借阅",
"digital_access": "数字版可用",
"to_acquire": "待获取"
},
"email": {
"passwordResetSubject": "密码重置 – LibrisLog",
"passwordResetBody": "<html>\n<body>\n<p>您已请求重置 LibrisLog 帐户的密码。</p>\n<p>点击下面的链接重置您的密码。此链接有效期为 {duration_minutes} 分钟。</p>\n<p><a href=\"{reset_url}\">{reset_url}</a></p>\n<p>如果您没有请求此操作,请忽略此邮件。</p>\n</body>\n</html>"
Expand Down
29 changes: 29 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""SQLModel ORM models for LibrisLog database tables."""

from enum import Enum
import re
from typing import Optional
from datetime import date, datetime, timezone

Expand Down Expand Up @@ -53,6 +54,33 @@ class AcquisitionStatus(str, Enum):
to_acquire = "to_acquire"


def normalize_medium_key(value: str) -> str:
"""Normalize a medium display value or enum key for comparisons."""
return re.sub(r"[\s/]+", "_", value.strip().lower())


class Medium(str, Enum):
"""Enum of a book's physical or digital medium format."""

print = "Print"
ebook = "eBook"
audiobook = "Audiobook"
comic_graphic_novel = "Comic / Graphic Novel"
magazine_newspaper = "Magazine / Newspaper"

@classmethod
def _missing_(cls, value: object) -> "Medium | None":
"""Accept enum keys and normalized display values at API boundaries."""
if not isinstance(value, str):
return None
normalized = normalize_medium_key(value)
for member in cls:
member_value = normalize_medium_key(member.value)
if normalized in {member.name, member_value}:
return member
return None


class UserRole(str, Enum):
"""Enum of possible user roles."""

Expand Down Expand Up @@ -88,6 +116,7 @@ def normalize_empty_cover_url(cls, data: dict) -> dict:
rating: Optional[int] = Field(default=None, ge=1, le=5)
reading_status: ReadingStatus = Field(default=ReadingStatus.want_to_read, index=True)
acquisition_status: AcquisitionStatus = Field(default=AcquisitionStatus.owned, index=True)
medium: Optional[Medium] = Field(default=None, index=True)
user_id: Optional[int] = Field(default=None, foreign_key="user.id", index=True)
date_added: datetime = Field(
default_factory=utcnow,
Expand Down
12 changes: 8 additions & 4 deletions backend/app/routers/books.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from app.auth import require_user
from app.config import settings
from app.database import get_session
from app.models import AcquisitionStatus, Author, Book, BookAuthor, BookTag, ReadingProgress, ReadingStatus, Tag, User
from app.models import AcquisitionStatus, Author, Book, BookAuthor, BookTag, Medium, ReadingProgress, ReadingStatus, Tag, User
from app.schemas import (
BookCreate,
BookListResponse,
Expand Down Expand Up @@ -152,11 +152,12 @@ def _build_book_read_with_tags(book: Book, tags_text: str | None, authors: list[
def list_books(
status: Optional[ReadingStatus] = Query(default=None),
acquisition_status: Optional[AcquisitionStatus] = Query(default=None),
medium: Optional[Medium] = Query(default=None),
q: Optional[str] = Query(
default=None,
description=(
"Search phrase. Use <field>:<value> to restrict a term to a single field "
"(author, publisher, title, tag, language, possession, notes, description). "
"(author, publisher, title, tag, language, possession, medium, notes, description). "
"Wrap multi-word values in double quotes (e.g. author:\"Marlen Haushofer\") and "
"prefix any term with - to negate it (e.g. tag:cars -tag:audi)."
),
Expand All @@ -179,8 +180,8 @@ def list_books(
read → date_finished, did_not_finish → date_started (all descending).
"""
logger.debug(
"list_books — status=%r q=%r sort=%s order=%s smart_sort=%s",
status, q, sort, order, smart_sort,
"list_books — status=%r acquisition=%r medium=%r q=%r sort=%s order=%s smart_sort=%s",
status, acquisition_status, medium, q, sort, order, smart_sort,
)
base_statement = select(Book).where(Book.user_id == current_user.id)

Expand All @@ -190,6 +191,9 @@ def list_books(
if acquisition_status is not None:
base_statement = base_statement.where(Book.acquisition_status == acquisition_status)

if medium is not None:
base_statement = base_statement.where(Book.medium == medium)

if q:
assert current_user.id is not None
base_statement = apply_search_filter(base_statement, q, current_user.id)
Expand Down
1 change: 1 addition & 0 deletions backend/app/routers/import_.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ async def import_book(
blurb=c.blurb,
reading_status=body.reading_status,
acquisition_status=body.acquisition_status,
medium=body.medium,
user_id=current_user.id,
)
session.add(book)
Expand Down
15 changes: 14 additions & 1 deletion backend/app/routers/statistics.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from app.auth import require_user
from app.database import get_session
from app.models import AcquisitionStatus, Author, Book, BookAuthor, ReadingProgress, ReadingStatus, User, UserSettings
from app.models import AcquisitionStatus, Author, Book, BookAuthor, Medium, ReadingProgress, ReadingStatus, User, UserSettings
from app.services.authors import join_authors, load_authors_batch
from app.schemas import (
AcquisitionStatusDistribution,
Expand All @@ -26,6 +26,7 @@
LanguageDistribution,
MonthlyBooks,
MonthlyPages,
MediumDistribution,
PageBuckets,
StatisticsRange,
StatisticsResponse,
Expand Down Expand Up @@ -751,6 +752,17 @@ def get_statistics(
to_acquire=acquisition_counts.get(AcquisitionStatus.to_acquire, 0),
)

medium_distribution = [
MediumDistribution(
medium=medium,
count=sum(1 for book in books if book.medium == medium),
)
for medium in Medium
]
unset_medium_count = sum(1 for book in books if book.medium is None)
if unset_medium_count:
medium_distribution.append(MediumDistribution(medium=None, count=unset_medium_count))

page_values = [book.page_count for book in books if book.page_count is not None]
avg_page_count = round(mean(page_values), 2) if page_values else None

Expand Down Expand Up @@ -1093,6 +1105,7 @@ def _rating_sort_key(book: Book) -> tuple[int, float]:
language_distribution=language_distribution,
status_distribution=status_distribution,
acquisition_status_distribution=acquisition_status_distribution,
medium_distribution=medium_distribution,
page_buckets=page_buckets,
pages_read_per_month=pages_read_per_month,
books_finished_per_month=books_finished_per_month,
Expand Down
13 changes: 12 additions & 1 deletion backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from sqlmodel import Field, SQLModel
from sqlmodel._compat import SQLModelConfig

from app.models import AcquisitionStatus, ReadingStatus, UserRole
from app.models import AcquisitionStatus, Medium, ReadingStatus, UserRole


class ReadingProgressCreate(SQLModel):
Expand Down Expand Up @@ -74,6 +74,7 @@ def require_author(cls, data: Any) -> Any:
rating: Optional[int] = Field(default=None, ge=1, le=5)
reading_status: ReadingStatus = ReadingStatus.want_to_read
acquisition_status: AcquisitionStatus = AcquisitionStatus.owned
medium: Optional[Medium] = None
date_started: Optional[datetime] = None
date_finished: Optional[datetime] = None

Expand All @@ -96,6 +97,7 @@ class BookUpdate(SQLModel):
rating: Optional[int] = Field(default=None, ge=1, le=5)
reading_status: Optional[ReadingStatus] = None
acquisition_status: Optional[AcquisitionStatus] = None
medium: Optional[Medium] = None
date_started: Optional[datetime] = None
date_finished: Optional[datetime] = None

Expand Down Expand Up @@ -144,6 +146,7 @@ class BookImportRequest(SQLModel):
candidate: BookImportCandidate
reading_status: ReadingStatus = ReadingStatus.want_to_read
acquisition_status: AcquisitionStatus = AcquisitionStatus.owned
medium: Optional[Medium] = None


class BookRead(SQLModel):
Expand All @@ -165,6 +168,7 @@ class BookRead(SQLModel):
rating: Optional[int]
reading_status: ReadingStatus
acquisition_status: AcquisitionStatus
medium: Optional[Medium] = None
date_added: datetime
date_started: Optional[datetime]
date_finished: Optional[datetime]
Expand Down Expand Up @@ -224,6 +228,12 @@ class AcquisitionStatusDistribution(SQLModel):
to_acquire: int


class MediumDistribution(SQLModel):
"""Count of books per medium, including unset values."""
medium: Optional[Medium]
count: int


class PageBuckets(SQLModel):
"""Page count buckets for the statistics dashboard."""
pages_to_read: int
Expand Down Expand Up @@ -298,6 +308,7 @@ class StatisticsResponse(SQLModel):
language_distribution: list[LanguageDistribution]
status_distribution: StatusDistribution
acquisition_status_distribution: AcquisitionStatusDistribution
medium_distribution: list[MediumDistribution]
page_buckets: PageBuckets
pages_read_per_month: list[MonthlyPages]
books_finished_per_month: list[MonthlyBooks]
Expand Down
2 changes: 2 additions & 0 deletions backend/app/services/data_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"rating",
"reading_status",
"acquisition_status",
"medium",
"date_added",
"date_started",
"date_finished",
Expand Down Expand Up @@ -80,6 +81,7 @@ def _book_to_dict(session: Session, book: Book, export_format: str) -> dict:
"rating": book.rating,
"reading_status": book.reading_status.value,
"acquisition_status": book.acquisition_status.value,
"medium": book.medium.value if book.medium else None,
"date_added": _serialize_datetime(book.date_added),
"date_started": _serialize_datetime(book.date_started),
"date_finished": _serialize_datetime(book.date_finished),
Expand Down
Loading