From 8bf610c30952b994c041706764fbee5ef40dc66e Mon Sep 17 00:00:00 2001 From: codebude Date: Wed, 9 Sep 2026 05:39:33 +0200 Subject: [PATCH 1/2] Add optional medium attribute to books --- .../7a8b9c0d1e2f_add_medium_to_books.py | 29 +++++++++++++ backend/app/models.py | 29 +++++++++++++ backend/app/routers/books.py | 12 ++++-- backend/app/routers/import_.py | 1 + backend/app/routers/statistics.py | 15 ++++++- backend/app/schemas.py | 13 +++++- backend/app/services/data_export.py | 2 + backend/app/services/data_import.py | 24 ++++++++++- backend/app/services/search.py | 19 +++++++-- backend/tests/test_books.py | 33 +++++++++++++++ backend/tests/test_data.py | 7 +++- backend/tests/test_data_import.py | 11 +++++ backend/tests/test_search_query.py | 18 +++++++- docs/api/index.md | 4 +- docs/guide/database-layout.md | 2 + docs/guide/using-librislog/import-export.md | 8 ++-- docs/guide/using-librislog/library.md | 6 ++- docs/guide/using-librislog/search.md | 8 ++++ docs/releases.md | 5 ++- frontend/src/lib/api.ts | 7 +++- .../src/lib/components/AddBookModal.svelte | 21 +++++++++- frontend/src/lib/components/BookCard.test.ts | 1 + .../lib/components/BookDetailDialog.svelte | 13 ++++++ .../lib/components/BookDetailDialog.test.ts | 1 + frontend/src/lib/components/BookDrawer.svelte | 23 ++++++++++- .../src/lib/components/BookDrawer.test.ts | 1 + .../src/lib/components/ImportSearch.svelte | 22 +++++++++- .../src/lib/components/ImportSearch.test.ts | 2 + frontend/src/lib/components/SearchHelp.svelte | 6 ++- frontend/src/lib/i18n/locales/de.json | 14 ++++++- frontend/src/lib/i18n/locales/en.json | 14 ++++++- frontend/src/lib/i18n/locales/es.json | 14 ++++++- frontend/src/lib/i18n/locales/fr.json | 14 ++++++- frontend/src/lib/i18n/locales/zh.json | 14 ++++++- frontend/src/lib/types.ts | 8 ++++ frontend/src/routes/library/+page.svelte | 17 +++++++- frontend/src/routes/library/page.test.ts | 3 +- frontend/src/routes/search/page.test.ts | 1 + frontend/src/routes/statistics/+page.svelte | 41 +++++++++++++++++++ 39 files changed, 446 insertions(+), 37 deletions(-) create mode 100644 backend/alembic/versions/7a8b9c0d1e2f_add_medium_to_books.py diff --git a/backend/alembic/versions/7a8b9c0d1e2f_add_medium_to_books.py b/backend/alembic/versions/7a8b9c0d1e2f_add_medium_to_books.py new file mode 100644 index 00000000..e3f0976e --- /dev/null +++ b/backend/alembic/versions/7a8b9c0d1e2f_add_medium_to_books.py @@ -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") diff --git a/backend/app/models.py b/backend/app/models.py index e214ab22..2b222440 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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 @@ -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.""" @@ -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, diff --git a/backend/app/routers/books.py b/backend/app/routers/books.py index aceadb64..a1ca29f0 100644 --- a/backend/app/routers/books.py +++ b/backend/app/routers/books.py @@ -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, @@ -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 : 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)." ), @@ -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) @@ -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) diff --git a/backend/app/routers/import_.py b/backend/app/routers/import_.py index acde920c..8df096cd 100644 --- a/backend/app/routers/import_.py +++ b/backend/app/routers/import_.py @@ -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) diff --git a/backend/app/routers/statistics.py b/backend/app/routers/statistics.py index 056fb296..523dd505 100644 --- a/backend/app/routers/statistics.py +++ b/backend/app/routers/statistics.py @@ -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, @@ -26,6 +26,7 @@ LanguageDistribution, MonthlyBooks, MonthlyPages, + MediumDistribution, PageBuckets, StatisticsRange, StatisticsResponse, @@ -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 @@ -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, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 99eb262b..9ce1cb12 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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): @@ -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 @@ -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 @@ -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): @@ -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] @@ -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 @@ -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] diff --git a/backend/app/services/data_export.py b/backend/app/services/data_export.py index 18e97206..f105b517 100644 --- a/backend/app/services/data_export.py +++ b/backend/app/services/data_export.py @@ -33,6 +33,7 @@ "rating", "reading_status", "acquisition_status", + "medium", "date_added", "date_started", "date_finished", @@ -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), diff --git a/backend/app/services/data_import.py b/backend/app/services/data_import.py index 89144b49..8e18be4a 100644 --- a/backend/app/services/data_import.py +++ b/backend/app/services/data_import.py @@ -15,7 +15,7 @@ from sqlmodel import Session, col, select from app.config import settings -from app.models import AcquisitionStatus, Book, ReadingProgress, ReadingStatus, User +from app.models import AcquisitionStatus, Book, Medium, ReadingProgress, ReadingStatus, User, normalize_medium_key from app.schemas import ImportFieldConfig logger = logging.getLogger(__name__) @@ -40,6 +40,7 @@ "rating", "reading_status", "acquisition_status", + "medium", "date_added", "date_started", "date_finished", @@ -81,6 +82,10 @@ "acquisition": "acquisition_status", "availability": "acquisition_status", "ownership": "acquisition_status", + "medium": "medium", + "book medium": "medium", + "format": "medium", + "media type": "medium", "date added": "date_added", "added": "date_added", "date started": "date_started", @@ -307,6 +312,19 @@ def _parse_acquisition_status(value: object) -> AcquisitionStatus: raise ValueError(_format_value_error("acquisition_status", f"one of: {choices}", value)) from exc +def _parse_medium(value: object) -> Medium | None: + """Parse an optional book medium from an import row.""" + if value is None or not str(value).strip(): + return None + normalized = normalize_medium_key(str(value)) + for medium in Medium: + enum_value = normalize_medium_key(medium.value) + if normalized in {medium.name, enum_value}: + return medium + choices = ", ".join(medium.value for medium in Medium) + raise ValueError(_format_value_error("medium", f"one of: {choices}", value)) + + def _parse_year(value: object, field: str) -> int | None: """Parse a year value, accepting 4-digit integers and date strings.""" if value is None or value == "": @@ -565,6 +583,7 @@ def validate_import( reading_status = _parse_reading_status(row_data.get("reading_status")) if require_acquisition_status: _parse_acquisition_status(row_data.get("acquisition_status")) + _parse_medium(row_data.get("medium")) _normalize_language( None if row_data.get("language") is None else str(row_data.get("language")) ) @@ -685,6 +704,7 @@ def preview_import( reading_status = _parse_reading_status(row_data.get("reading_status")) if require_acquisition_status: _parse_acquisition_status(row_data.get("acquisition_status")) + _parse_medium(row_data.get("medium")) _normalize_language( None if row_data.get("language") is None else str(row_data.get("language")) ) @@ -807,6 +827,7 @@ async def execute_import( if require_acquisition_status else AcquisitionStatus.owned ) + medium = _parse_medium(row_data.get("medium")) language = _normalize_language( None if row_data.get("language") is None else str(row_data.get("language")) @@ -869,6 +890,7 @@ async def execute_import( rating=rating, reading_status=reading_status, acquisition_status=acquisition_status, + medium=medium, date_added=date_added or utcnow(), date_started=date_started, date_finished=date_finished, diff --git a/backend/app/services/search.py b/backend/app/services/search.py index cc1a757a..43f231ac 100644 --- a/backend/app/services/search.py +++ b/backend/app/services/search.py @@ -16,7 +16,7 @@ import sqlalchemy as sa from sqlmodel import col, or_, select -from app.models import AcquisitionStatus, Author, Book, BookAuthor, BookTag, Tag +from app.models import AcquisitionStatus, Author, Book, BookAuthor, BookTag, Medium, Tag, normalize_medium_key # Fields that can be targeted with a prefix. The keys are the canonical, # always-English prefix names; the values are the book model columns. @@ -30,11 +30,12 @@ # Possession is a special case: it maps to an exact enum comparison. POSSESSION_PREFIX = "possession" +MEDIUM_PREFIX = "medium" TAG_PREFIX = "tag" AUTHOR_PREFIX = "author" SUPPORTED_PREFIXES: frozenset[str] = frozenset( - [*FIELD_COLUMNS.keys(), POSSESSION_PREFIX, TAG_PREFIX, AUTHOR_PREFIX] + [*FIELD_COLUMNS.keys(), POSSESSION_PREFIX, MEDIUM_PREFIX, TAG_PREFIX, AUTHOR_PREFIX] ) # Default fields searched by an unprefixed term (unchanged from the previous @@ -178,10 +179,22 @@ def _possession_condition(value: str) -> Any | None: return Book.acquisition_status == status +def _medium_condition(value: str) -> Any | None: + """Build an exact medium condition, accepting display and key forms.""" + normalized = normalize_medium_key(value) + for medium in Medium: + enum_value = normalize_medium_key(medium.value) + if normalized in {medium.name, enum_value}: + return Book.medium == medium + return None + + def _field_condition(field: str, value: str, user_id: int) -> Any | None: """Build the condition for a single field-specific term.""" if field == POSSESSION_PREFIX: return _possession_condition(value) + if field == MEDIUM_PREFIX: + return _medium_condition(value) if field == TAG_PREFIX: return _tag_condition(value, user_id) if field == AUTHOR_PREFIX: @@ -221,4 +234,4 @@ def apply_search_filter(statement: Any, query: str, user_id: int) -> Any: if conditions: return statement.where(sa.and_(*conditions)) - return statement \ No newline at end of file + return statement diff --git a/backend/tests/test_books.py b/backend/tests/test_books.py index c2eb0c65..76b866c4 100644 --- a/backend/tests/test_books.py +++ b/backend/tests/test_books.py @@ -35,6 +35,39 @@ def test_create_book_returns_201(client: TestClient) -> None: assert data["reading_status"] == "want_to_read" +def test_create_and_filter_books_by_medium(client: TestClient) -> None: + created = _create_book(client, title="Audio", medium="Audiobook") + assert created["medium"] == "Audiobook" + assert _create_book(client, title="Print", medium="Print")["medium"] == "Print" + + response = client.get("/api/books?medium=Audiobook") + assert response.status_code == 200 + assert [book["title"] for book in response.json()["books"]] == ["Audio"] + + search = client.get("/api/books?q=medium:audiobook") + assert search.status_code == 200 + assert [book["title"] for book in search.json()["books"]] == ["Audio"] + + +def test_update_book_medium_can_be_set_and_cleared(client: TestClient) -> None: + book = _create_book(client, medium="Print") + updated = client.patch(f"/api/books/{book['id']}", json={"medium": "audiobook"}) + assert updated.status_code == 200 + assert updated.json()["medium"] == "Audiobook" + + cleared = client.patch(f"/api/books/{book['id']}", json={"medium": None}) + assert cleared.status_code == 200 + assert cleared.json()["medium"] is None + + +def test_create_book_rejects_invalid_medium(client: TestClient) -> None: + response = client.post( + "/api/books", + json={"title": "Invalid", "author": "Author", "page_count": 100, "medium": "vinyl"}, + ) + assert response.status_code == 422 + + def test_create_book_with_all_fields(client: TestClient) -> None: payload = { "title": "Dune", diff --git a/backend/tests/test_data.py b/backend/tests/test_data.py index d04d291d..2b39245d 100644 --- a/backend/tests/test_data.py +++ b/backend/tests/test_data.py @@ -24,7 +24,7 @@ def _parse_sse(text: str) -> list[dict[str, str | int | bool | None]]: def test_data_export_zip_contains_manifest_and_books_json(client: TestClient) -> None: create_resp = client.post( "/api/books", - json={"title": "Dune", "author": "Frank Herbert", "page_count": 412, "reading_status": "read"}, + json={"title": "Dune", "author": "Frank Herbert", "page_count": 412, "reading_status": "read", "medium": "Print"}, ) assert create_resp.status_code == 201 @@ -46,12 +46,13 @@ def test_data_export_zip_contains_manifest_and_books_json(client: TestClient) -> assert manifest["counts"]["books"] == 1 books = json.loads(zf.read("books.json")) assert books[0]["title"] == "Dune" + assert books[0]["medium"] == "Print" def test_data_export_csv_format(client: TestClient) -> None: create_resp = client.post( "/api/books", - json={"title": "Dune", "author": "Frank Herbert", "page_count": 412, "reading_status": "read"}, + json={"title": "Dune", "author": "Frank Herbert", "page_count": 412, "reading_status": "read", "medium": "Audiobook"}, ) assert create_resp.status_code == 201 @@ -68,6 +69,8 @@ def test_data_export_csv_format(client: TestClient) -> None: assert "tags.csv" in names books_csv = zf.read("books.csv").decode() assert "title,subtitle" in books_csv + assert "medium" in books_csv.splitlines()[0] + assert "Audiobook" in books_csv assert "Dune" in books_csv diff --git a/backend/tests/test_data_import.py b/backend/tests/test_data_import.py index d9ea2869..2d1f36e8 100644 --- a/backend/tests/test_data_import.py +++ b/backend/tests/test_data_import.py @@ -1216,6 +1216,17 @@ def test_parse_acquisition_status_missing_value() -> None: di._parse_acquisition_status(" ") +def test_parse_medium_accepts_optional_display_and_key_values() -> None: + assert di._parse_medium(None) is None + assert di._parse_medium("") is None + audiobook = di._parse_medium("Audiobook") + comic = di._parse_medium("comic_graphic_novel") + assert audiobook is not None and audiobook.value == "Audiobook" + assert comic is not None and comic.value == "Comic / Graphic Novel" + with pytest.raises(ValueError, match="Invalid value for 'medium'"): + di._parse_medium("unknown") + + # ── _mapped_row ─────────────────────────────────────────────────────────────── def test_mapped_row_transform_execution_error() -> None: diff --git a/backend/tests/test_search_query.py b/backend/tests/test_search_query.py index a288f4c3..47908cd3 100644 --- a/backend/tests/test_search_query.py +++ b/backend/tests/test_search_query.py @@ -86,7 +86,7 @@ def test_parse_bare_prefix() -> None: def test_parse_all_supported_prefixes() -> None: - query = "author:a title:t publisher:p tag:g language:en possession:owned notes:n description:d" + query = "author:a title:t publisher:p tag:g language:en possession:owned medium:audiobook notes:n description:d" fields = [t.field for t in parse_search_query(query)] assert fields == [ "author", @@ -95,6 +95,7 @@ def test_parse_all_supported_prefixes() -> None: "tag", "language", "possession", + "medium", "notes", "description", ] @@ -112,4 +113,17 @@ def test_possession_condition_accepts_enum_values() -> None: def test_possession_condition_rejects_unknown_value() -> None: from app.services.search import _possession_condition - assert _possession_condition("not-a-status") is None \ No newline at end of file + assert _possession_condition("not-a-status") is None + + +def test_medium_condition_accepts_display_and_key_values() -> None: + from app.services.search import _medium_condition + + assert _medium_condition("Audiobook") is not None + assert _medium_condition("comic_graphic_novel") is not None + assert _medium_condition("Comic / Graphic Novel") is not None + assert _medium_condition("unknown") is None + + +def test_parse_negated_medium_term() -> None: + assert _terms("-medium:print") == [("medium", "print", True)] diff --git a/docs/api/index.md b/docs/api/index.md index a972eb6a..82c6ec2f 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -61,6 +61,8 @@ The `author` field is marked as **deprecated** in the OpenAPI spec (visible in S When creating a book you must provide at least one author — either `authors` as a list, or the legacy `author` string. If both are sent, `authors` takes precedence. A request with neither (or with an empty `authors` list) is rejected with a `422` validation error. +Books have an optional nullable `medium` field. Accepted values are `Print`, `eBook`, `Audiobook`, `Comic / Graphic Novel`, and `Magazine / Newspaper`. Omit it or send `null` when the medium is not known. Books can be filtered with `GET /api/books?medium=Audiobook` or searched with `q=medium:audiobook`. + For updates, `author`/`authors` are optional; if you send an empty `authors` list the book's authors are cleared. The legacy `author` string is **parsed on commas, tag-style** (e.g. `"Isaac Asimov, Frank Herbert"` becomes two authors). This only applies to the API create/update path. It differs from **file import** (CSV/JSON), where a single author string is split on `;`, ` & `, or ` and ` — never on commas — so a name like `"Asimov, Isaac"` stays one author. See [Import & Export](../guide/using-librislog/import-export.md) for the import behaviour. @@ -172,4 +174,4 @@ Error responses include a JSON body with details: { "detail": "Book not found" } -``` \ No newline at end of file +``` diff --git a/docs/guide/database-layout.md b/docs/guide/database-layout.md index 8d96c83f..f69d0f6c 100644 --- a/docs/guide/database-layout.md +++ b/docs/guide/database-layout.md @@ -70,6 +70,7 @@ erDiagram integer rating varchar reading_status varchar acquisition_status + varchar medium integer user_id datetime date_added datetime date_started @@ -215,6 +216,7 @@ A book in the user's library. | `rating` | `INTEGER` | | ≥ 1; ≤ 5 | | `reading_status` | `VARCHAR` | NOT NULL, INDEX | default `want_to_read` | | `acquisition_status` | `VARCHAR` | NOT NULL, INDEX | default `owned` | +| `medium` | `VARCHAR` | INDEX, nullable | Stored as the enum key (`print`, `ebook`, `audiobook`, `comic_graphic_novel`, or `magazine_newspaper`) | | `user_id` | `INTEGER` | FK → user.id, INDEX | | | `date_added` | `DATETIME` | INDEX | UTC | | `date_started` | `DATETIME` | INDEX | UTC | diff --git a/docs/guide/using-librislog/import-export.md b/docs/guide/using-librislog/import-export.md index 8b21b094..102a2a4b 100644 --- a/docs/guide/using-librislog/import-export.md +++ b/docs/guide/using-librislog/import-export.md @@ -15,7 +15,7 @@ The most common way to add books is by searching external sources: - **Google Books** (if `GOOGLE_BOOKS_API_KEY` is set — see [API Keys](/guide/api-keys)) - **Hardcover.app** (if `HARDCOVER_APP_API_TOKEN` is set — see [API Keys](/guide/api-keys)) 4. Select a result to import with full metadata and cover -5. Choose an availability value (owned, borrowed, digital access, or to acquire) before saving +5. Choose an availability value and, optionally, a medium (Print, eBook, Audiobook, Comic / Graphic Novel, or Magazine / Newspaper) before saving ### ISBN Barcode Scan @@ -23,11 +23,11 @@ On mobile devices: 1. Tap the scan button in the import dialog 2. Point the camera at an ISBN barcode 3. The app detects the barcode and searches automatically -4. Pick the search result and select an availability value before saving +4. Pick the search result and select an availability value and optional medium before saving ### Manual Entry -If no search results are found, enter book details manually. Title, author, page count, and availability are required; all other fields are optional. +If no search results are found, enter book details manually. Title, author, page count, and availability are required; all other fields, including medium, are optional. Authors can be added as multiple values: type a name and press **Enter** (or pick a suggestion) to add a chip. A book can have any number of authors. Commas inside an author name (e.g. `Asimov, Isaac`) are preserved — they are not treated as separators. @@ -86,6 +86,8 @@ When importing CSV, map source columns to LibrisLog fields: `acquisition_status` is required for imports. Map it to one of `owned`, `borrowed`, `digital_access`, or `to_acquire`; use a transform when the source file uses different names. +The optional `medium` field can be mapped to `Print`, `eBook`, `Audiobook`, `Comic / Graphic Novel`, or `Magazine / Newspaper`. Existing exports include this field and preserve an unset medium as empty/null. + `date_added` is importable too — useful when migrating from another tool so the original "added to library" dates are preserved (the LibrisLog JSON export includes it, so exports round-trip losslessly). If a row has no `date_added`, the import timestamp is used. #### Authors are adaptive diff --git a/docs/guide/using-librislog/library.md b/docs/guide/using-librislog/library.md index 7b998972..6a3198a3 100644 --- a/docs/guide/using-librislog/library.md +++ b/docs/guide/using-librislog/library.md @@ -19,6 +19,8 @@ Each status has its own tab in the library view, making it easy to browse your c Possession is separate from reading status. Choose whether a book is owned, borrowed, available digitally, or still needs to be acquired. In the Want to Read view, books that still need to be acquired show a shopping-cart indicator. Use the possession filter to narrow the list without changing its newest-first order. +Each book can also have an optional medium: Print, eBook, Audiobook, Comic / Graphic Novel, or Magazine / Newspaper. Use the medium filter to narrow the library, or search with `medium:audiobook`. A missing medium is valid when it is not known yet. + ![Library](/screenshots/library-read.png) ## Navigation @@ -39,7 +41,7 @@ Each book card shows: Clicking a book opens the detail dialog/drawer showing: - Full cover image -- Complete metadata (title, subtitle, author, ISBN, publisher, year, pages, language) +- Complete metadata (title, subtitle, author, ISBN, publisher, year, pages, language, medium) - Reading status badge - Star rating (clickable to change) - Reading progress slider (for books with page count) @@ -52,7 +54,7 @@ Clicking a book opens the detail dialog/drawer showing: ### Manual Entry -Use the "Add Book" button to manually enter book details. Fill in title, author, and optional fields like ISBN, publisher, page count, etc. +Use the "Add Book" button to manually enter book details. Fill in title, author, and optional fields like ISBN, publisher, page count, and medium. The medium can be left unset. A book can have **multiple authors**: type a name and press **Enter** to add it as a chip. Authors are shown joined with "; " throughout the app, so names written last-name-first (e.g. `"Doe, Jane"`) stay unambiguous. diff --git a/docs/guide/using-librislog/search.md b/docs/guide/using-librislog/search.md index abdfefac..fe351df9 100644 --- a/docs/guide/using-librislog/search.md +++ b/docs/guide/using-librislog/search.md @@ -14,6 +14,7 @@ Use `:` to search in a single field. The field prefixes are always | `language` | Language | `language:Japanese` | | `tag` | Tag name | `tag:fantasy` | | `possession` | Possession status | `possession:owned` | +| `medium` | Book medium | `medium:audiobook` | | `notes` | Private notes | `notes:"to reread"` | | `description` | Blurb / description | `description:"middle earth"` | @@ -32,6 +33,12 @@ The `possession` prefix matches the exact possession status. Accepted values inc Example: `possession:"to acquire"` shows books you want to buy. +### Medium values + +The `medium` prefix matches the selected book medium. Accepted values are `print`, `ebook`, `audiobook`, `comic_graphic_novel`, and `magazine_newspaper`. Display values such as `"Comic / Graphic Novel"` are accepted too. + +Example: `medium:audiobook` shows audiobook entries. The API list filter accepts both display values and normalized keys. + ## Negation Prefix a term with `-` to exclude matches. @@ -46,6 +53,7 @@ Separate terms with spaces. All terms are combined with **AND**. - `author:Murakami -title:Norwegian` — Murakami books except those whose title contains "Norwegian" - `tag:fantasy possession:owned` — owned fantasy books +- `medium:print possession:owned` — owned print books ## Plain text diff --git a/docs/releases.md b/docs/releases.md index b74d234a..bbaf6bd3 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -39,10 +39,11 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner -**Summary:** Groups duplicate import-search results into expandable edition groups, makes running searches cancelable, introduces an adaptive date input with a native picker, detects insecure camera contexts, and fixes timezone handling in the daily page statistics and progress log editing. +**Summary:** Groups duplicate import-search results into expandable edition groups, makes running searches cancelable, introduces an adaptive date input with a native picker, adds optional book media and medium statistics, detects insecure camera contexts, and fixes timezone handling in the daily page statistics and progress log editing. **Features** - 📚 **Edition groups in the import search**: results from different providers that describe the same book (same ISBN, or same title and authors) are now grouped into expandable entries with an "N results" badge. Compare the variants side by side and import the one you want; no result is dropped anymore. See the [Library guide](/guide/using-librislog/library#how-results-are-grouped) for the exact grouping rules +- 🗂️ **Optional book medium**: classify books as Print, eBook, Audiobook, Comic / Graphic Novel, or Magazine / Newspaper from manual entry, search import, and book editing. Mediums can be filtered in the library, searched with `medium:`, imported/exported, and reviewed in the statistics distribution - 🛑 **Cancelable book search**: while an import search is running, the Search button becomes a Cancel button, so you can stop the request and refine your query - ⌨️ **Escape closes dialogs and drawers**: overlays such as the sidebar, the book drawer, and the import modal can now be closed with the Escape key - 📅 **Adaptive date input**: date fields in the book form now use a segmented year/month/day input that no longer assumes the month or day after the first keystroke, validates values as you type, and accepts pasting a complete date. A calendar button next to the field opens the native date picker @@ -353,4 +354,4 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner - 🐳 Self-hosted via Docker Compose (SQLite, lightweight setup) - 🎨 Light/dark themes and responsive UI -[Full changelog](https://github.com/codebude/librislog/commits/v1.0.0) \ No newline at end of file +[Full changelog](https://github.com/codebude/librislog/commits/v1.0.0) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 500c9d76..5011c216 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -28,6 +28,7 @@ import type { StatisticsResponse, StatisticsRange, LibraryStats, + Medium, ReadingProgressEntry, StatusTransitionRequest, StatusTransitionResponse, @@ -340,6 +341,7 @@ export const api = { list(params?: { status?: ReadingStatus; acquisition_status?: AcquisitionStatus; + medium?: Medium; q?: string; has_cover?: boolean; sort?: SortField; @@ -351,6 +353,7 @@ export const api = { const qs = new URLSearchParams(); if (params?.status) qs.set('status', params.status); if (params?.acquisition_status) qs.set('acquisition_status', params.acquisition_status); + if (params?.medium) qs.set('medium', params.medium); 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); @@ -477,10 +480,10 @@ export const api = { ); }, - importBook(candidate: BookImportCandidate, status: ReadingStatus, acquisitionStatus: AcquisitionStatus): Promise { + importBook(candidate: BookImportCandidate, status: ReadingStatus, acquisitionStatus: AcquisitionStatus, medium?: Medium | null): Promise { return request('/import', { method: 'POST', - body: JSON.stringify({ candidate, reading_status: status, acquisition_status: acquisitionStatus }) + body: JSON.stringify({ candidate, reading_status: status, acquisition_status: acquisitionStatus, medium }) }); }, diff --git a/frontend/src/lib/components/AddBookModal.svelte b/frontend/src/lib/components/AddBookModal.svelte index 581abd39..5493de08 100644 --- a/frontend/src/lib/components/AddBookModal.svelte +++ b/frontend/src/lib/components/AddBookModal.svelte @@ -1,5 +1,5 @@ {#if open} @@ -224,6 +234,15 @@ {/each} +