From 6ce47af5d027ffc25dbd9dd2c60d5d057741bf4e Mon Sep 17 00:00:00 2001 From: codebude Date: Wed, 26 Aug 2026 06:44:24 +0200 Subject: [PATCH 1/3] Renamed availability to possession --- backend/app/routers/books.py | 2 +- backend/app/services/search.py | 14 ++++++------ backend/tests/test_books.py | 12 +++++----- backend/tests/test_search_query.py | 22 +++++++++---------- docs/guide/using-librislog/library.md | 4 ++-- docs/guide/using-librislog/search.md | 10 ++++----- .../e2e/specs/03-library-browsing.spec.ts | 6 ++--- .../src/lib/components/AddBookModal.test.ts | 2 +- frontend/src/lib/components/SearchHelp.svelte | 4 ++-- .../src/lib/components/SearchHelp.test.ts | 2 +- frontend/src/lib/i18n/locales/de.json | 2 +- frontend/src/lib/i18n/locales/en.json | 6 ++--- frontend/src/lib/i18n/locales/es.json | 2 +- frontend/src/lib/i18n/locales/fr.json | 2 +- frontend/src/lib/i18n/locales/zh.json | 2 +- 15 files changed, 46 insertions(+), 46 deletions(-) diff --git a/backend/app/routers/books.py b/backend/app/routers/books.py index c6c5e8de..aceadb64 100644 --- a/backend/app/routers/books.py +++ b/backend/app/routers/books.py @@ -156,7 +156,7 @@ def list_books( default=None, description=( "Search phrase. Use : to restrict a term to a single field " - "(author, publisher, title, tag, language, availability, notes, description). " + "(author, publisher, title, tag, language, possession, notes, description). " "Wrap multi-word values in double quotes (e.g. author:\"Marlen Haushofer\") and " "prefix any term with - to negate it (e.g. tag:cars -tag:audi)." ), diff --git a/backend/app/services/search.py b/backend/app/services/search.py index 65117959..cc1a757a 100644 --- a/backend/app/services/search.py +++ b/backend/app/services/search.py @@ -28,13 +28,13 @@ "description": Book.blurb, } -# Availability is a special case: it maps to an exact enum comparison. -AVAILABILITY_PREFIX = "availability" +# Possession is a special case: it maps to an exact enum comparison. +POSSESSION_PREFIX = "possession" TAG_PREFIX = "tag" AUTHOR_PREFIX = "author" SUPPORTED_PREFIXES: frozenset[str] = frozenset( - [*FIELD_COLUMNS.keys(), AVAILABILITY_PREFIX, TAG_PREFIX, AUTHOR_PREFIX] + [*FIELD_COLUMNS.keys(), POSSESSION_PREFIX, TAG_PREFIX, AUTHOR_PREFIX] ) # Default fields searched by an unprefixed term (unchanged from the previous @@ -168,7 +168,7 @@ def _unprefixed_condition(value: str, user_id: int) -> Any: ) -def _availability_condition(value: str) -> Any | None: +def _possession_condition(value: str) -> Any | None: """Build the exact acquisition-status condition, or ``None`` if invalid.""" normalized = value.strip().lower().replace(" ", "_") try: @@ -180,8 +180,8 @@ def _availability_condition(value: str) -> Any | None: def _field_condition(field: str, value: str, user_id: int) -> Any | None: """Build the condition for a single field-specific term.""" - if field == AVAILABILITY_PREFIX: - return _availability_condition(value) + if field == POSSESSION_PREFIX: + return _possession_condition(value) if field == TAG_PREFIX: return _tag_condition(value, user_id) if field == AUTHOR_PREFIX: @@ -212,7 +212,7 @@ def apply_search_filter(statement: Any, query: str, user_id: int) -> Any: condition = _field_condition(term.field, term.value, user_id) if condition is None: - # Invalid availability value: positive yields no rows, negated is a no-op. + # Invalid possession value: positive yields no rows, negated is a no-op. conditions.append(sa.false() if not term.negated else sa.true()) elif term.negated: conditions.append(sa.not_(condition)) diff --git a/backend/tests/test_books.py b/backend/tests/test_books.py index 32567309..c2eb0c65 100644 --- a/backend/tests/test_books.py +++ b/backend/tests/test_books.py @@ -281,19 +281,19 @@ def test_list_books_search_by_language(client: TestClient) -> None: assert body["books"][0]["title"] == "Dune" -def test_list_books_search_by_availability(client: TestClient) -> None: +def test_list_books_search_by_possession(client: TestClient) -> None: _create_book(client, title="Borrowed", acquisition_status="borrowed") _create_book(client, title="Owned", acquisition_status="owned") - resp = client.get("/api/books?q=availability:borrowed") + resp = client.get("/api/books?q=possession:borrowed") assert resp.status_code == 200 body = resp.json() assert body["total"] == 1 assert body["books"][0]["title"] == "Borrowed" -def test_list_books_search_by_availability_invalid_value(client: TestClient) -> None: +def test_list_books_search_by_possession_invalid_value(client: TestClient) -> None: _create_book(client, title="Borrowed", acquisition_status="borrowed") - resp = client.get("/api/books?q=availability:not-a-status") + resp = client.get("/api/books?q=possession:not-a-status") assert resp.status_code == 200 assert resp.json()["total"] == 0 @@ -393,10 +393,10 @@ def test_list_books_search_negation_includes_nullable_field_rows(client: TestCli assert [b["title"] for b in body["books"]] == ["Plain"] -def test_list_books_search_availability_quoted_multiword(client: TestClient) -> None: +def test_list_books_search_possession_quoted_multiword(client: TestClient) -> None: _create_book(client, title="Wanted", acquisition_status="to_acquire") _create_book(client, title="Owned", acquisition_status="owned") - resp = client.get('/api/books?q=availability:"to acquire"') + resp = client.get('/api/books?q=possession:"to acquire"') assert resp.status_code == 200 body = resp.json() assert [b["title"] for b in body["books"]] == ["Wanted"] diff --git a/backend/tests/test_search_query.py b/backend/tests/test_search_query.py index ad4af3c9..a288f4c3 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 availability:owned notes:n description:d" + query = "author:a title:t publisher:p tag:g language:en possession:owned notes:n description:d" fields = [t.field for t in parse_search_query(query)] assert fields == [ "author", @@ -94,22 +94,22 @@ def test_parse_all_supported_prefixes() -> None: "publisher", "tag", "language", - "availability", + "possession", "notes", "description", ] -def test_availability_condition_accepts_enum_values() -> None: - from app.services.search import _availability_condition +def test_possession_condition_accepts_enum_values() -> None: + from app.services.search import _possession_condition - assert _availability_condition("owned") is not None - assert _availability_condition("digital_access") is not None - assert _availability_condition("to acquire") is not None - assert _availability_condition("owned") is not None + assert _possession_condition("owned") is not None + assert _possession_condition("digital_access") is not None + assert _possession_condition("to acquire") is not None + assert _possession_condition("owned") is not None -def test_availability_condition_rejects_unknown_value() -> None: - from app.services.search import _availability_condition +def test_possession_condition_rejects_unknown_value() -> None: + from app.services.search import _possession_condition - assert _availability_condition("not-a-status") is None \ No newline at end of file + assert _possession_condition("not-a-status") is None \ No newline at end of file diff --git a/docs/guide/using-librislog/library.md b/docs/guide/using-librislog/library.md index d8bdf321..df4962cd 100644 --- a/docs/guide/using-librislog/library.md +++ b/docs/guide/using-librislog/library.md @@ -15,9 +15,9 @@ Books are categorized into four statuses: Each status has its own tab in the library view, making it easy to browse your collection by reading state. -## Availability +## Possession -Availability is separate from reading status. Choose whether a book is owned, borrowed, available digitally, or still needs to be acquired. In the Want to Read view, books that still need to be acquired show a shopping-cart indicator. Use the availability filter to narrow the list without changing its newest-first order. +Possession is separate from reading status. Choose whether a book is owned, borrowed, available digitally, or still needs to be acquired. In the Want to Read view, books that still need to be acquired show a shopping-cart indicator. Use the possession filter to narrow the list without changing its newest-first order. ![Library](/screenshots/library-read.png) diff --git a/docs/guide/using-librislog/search.md b/docs/guide/using-librislog/search.md index 8e08d2e1..abdfefac 100644 --- a/docs/guide/using-librislog/search.md +++ b/docs/guide/using-librislog/search.md @@ -13,7 +13,7 @@ Use `:` to search in a single field. The field prefixes are always | `publisher` | Publisher | `publisher:Penguin` | | `language` | Language | `language:Japanese` | | `tag` | Tag name | `tag:fantasy` | -| `availability` | Acquisition status | `availability:owned` | +| `possession` | Possession status | `possession:owned` | | `notes` | Private notes | `notes:"to reread"` | | `description` | Blurb / description | `description:"middle earth"` | @@ -21,16 +21,16 @@ Use quotes for values that contain spaces: `title:"The Silmarillion"`. The `author:` prefix matches **any** author assigned to a book — a book with multiple authors matches if any of them contains the search value. -### Availability values +### Possession values -The `availability` prefix matches the exact acquisition status. Accepted values include: +The `possession` prefix matches the exact possession status. Accepted values include: - `to_acquire` (or `to acquire`) - `owned` - `borrowed` - `digital` -Example: `availability:"to acquire"` shows books you want to buy. +Example: `possession:"to acquire"` shows books you want to buy. ## Negation @@ -45,7 +45,7 @@ Prefix a term with `-` to exclude matches. Separate terms with spaces. All terms are combined with **AND**. - `author:Murakami -title:Norwegian` — Murakami books except those whose title contains "Norwegian" -- `tag:fantasy availability:owned` — owned fantasy books +- `tag:fantasy possession:owned` — owned fantasy books ## Plain text diff --git a/frontend/e2e/specs/03-library-browsing.spec.ts b/frontend/e2e/specs/03-library-browsing.spec.ts index 0971e9c6..9b56f43b 100644 --- a/frontend/e2e/specs/03-library-browsing.spec.ts +++ b/frontend/e2e/specs/03-library-browsing.spec.ts @@ -67,14 +67,14 @@ test.describe('Library Browsing', () => { await expect(body).toContainText(/no books|empty/i); }); - test('3.5 manual creation requires availability and persists the selected value', async ({ page }) => { + test('3.5 manual creation requires possession and persists the selected value', async ({ page }) => { await deleteAllBooks(page); const library = new LibraryPage(page); await library.goto(); await page.getByRole('button', { name: '+ Add Book' }).click(); const modal = page.locator('.modal-box'); - const availability = modal.getByRole('combobox', { name: /Availability/ }); + const availability = modal.getByRole('combobox', { name: /Possession/ }); await expect(availability).toHaveValue(''); await modal.getByLabel('Title *').fill('Digital E2E Book'); @@ -91,7 +91,7 @@ test.describe('Library Browsing', () => { expect((await response.json()).books[0].acquisition_status).toBe('digital_access'); }); - test('3.6 filters Want to Read books by availability and marks books to acquire', async ({ page }) => { + test('3.6 filters Want to Read books by possession and marks books to acquire', async ({ page }) => { await deleteAllBooks(page); await createWantToReadBook(page, 'Owned E2E Book', 'owned'); await createWantToReadBook(page, 'Acquire E2E Book', 'to_acquire'); diff --git a/frontend/src/lib/components/AddBookModal.test.ts b/frontend/src/lib/components/AddBookModal.test.ts index bdac0797..698eff31 100644 --- a/frontend/src/lib/components/AddBookModal.test.ts +++ b/frontend/src/lib/components/AddBookModal.test.ts @@ -128,7 +128,7 @@ describe('AddBookModal', () => { fireEvent.keyDown(authorInput, { key: 'Enter' }); const pagesInput = screen.getByLabelText(/Pages/); fireEvent.input(pagesInput, { target: { value: '412' } }); - fireEvent.change(screen.getByRole('combobox', { name: /Availability/ }), { target: { value: 'owned' } }); + fireEvent.change(screen.getByRole('combobox', { name: /Possession/ }), { target: { value: 'owned' } }); } it('submits form and calls api.books.create', async () => { diff --git a/frontend/src/lib/components/SearchHelp.svelte b/frontend/src/lib/components/SearchHelp.svelte index fc56fce3..d058c455 100644 --- a/frontend/src/lib/components/SearchHelp.svelte +++ b/frontend/src/lib/components/SearchHelp.svelte @@ -20,7 +20,7 @@ { name: 'title', example: 'title:fragezeichen' }, { name: 'tag', example: 'tag:cars' }, { name: 'language', example: 'language:en' }, - { name: 'availability', example: 'availability:owned' }, + { name: 'possession', example: 'possession:owned' }, { name: 'notes', example: 'notes:reading' }, { name: 'description', example: 'description:desert' } ]; @@ -74,7 +74,7 @@ {$_('search.help.negate')}

- {$_('search.help.availabilityValues')} + {$_('search.help.possessionValues')}

{/if} diff --git a/frontend/src/lib/components/SearchHelp.test.ts b/frontend/src/lib/components/SearchHelp.test.ts index 1c319479..9bac602a 100644 --- a/frontend/src/lib/components/SearchHelp.test.ts +++ b/frontend/src/lib/components/SearchHelp.test.ts @@ -33,7 +33,7 @@ describe('SearchHelp', () => { expect(dialog).toHaveTextContent('title'); expect(dialog).toHaveTextContent('tag'); expect(dialog).toHaveTextContent('language'); - expect(dialog).toHaveTextContent('availability'); + expect(dialog).toHaveTextContent('possession'); expect(dialog).toHaveTextContent('notes'); expect(dialog).toHaveTextContent('description'); }); diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 480bf7b7..e94997aa 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -303,7 +303,7 @@ "multiWord": "Setze mehrteilige Werte in doppelte Anführungszeichen, z. B. author:\"Marlen Haushofer\".", "combine": "Du kannst mehrere Präfixe kombinieren; die Ergebnisse müssen allen entsprechen.", "negate": "Stelle einem Begriff ein - voran, um ihn auszuschließen, z. B. tag:cars -tag:audi.", - "availabilityValues": "Verfügbarkeitswerte: owned, borrowed, digital_access, to_acquire" + "possessionValues": "Possession-Werte: owned, borrowed, digital_access, to_acquire" } }, "languages": { diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index c75b2a74..12f73855 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -147,7 +147,7 @@ "blurb": "Description", "about": "About", "dateStarted": "Date started", - "acquisitionStatus": "Availability", + "acquisitionStatus": "Possession", "selectAcquisitionStatus": "Select availability...", "startDatePromptTitle": "Set a start date?", "startDatePromptMessage": "This book has no start date. Set it to today or choose another date before marking it as read.", @@ -303,7 +303,7 @@ "multiWord": "Wrap multi-word values in double quotes, e.g. author:\"Marlen Haushofer\".", "combine": "You can combine multiple prefixes; results must match all of them.", "negate": "Prefix any term with - to exclude it, e.g. tag:cars -tag:audi.", - "availabilityValues": "availability values: owned, borrowed, digital_access, to_acquire" + "possessionValues": "possession values: owned, borrowed, digital_access, to_acquire" } }, "languages": { @@ -722,4 +722,4 @@ "candidatesError": "Cover search failed. You can still use manual import.", "retry": "Retry" } -} +} \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index 3a1f4e9d..6a8d84dc 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -303,7 +303,7 @@ "multiWord": "Envuelve los valores de varias palabras entre comillas dobles, p. ej. author:\"Marlen Haushofer\".", "combine": "Puedes combinar varios prefijos; los resultados deben coincidir con todos.", "negate": "Antepón - a cualquier término para excluirlo, p. ej. tag:cars -tag:audi.", - "availabilityValues": "valores de disponibilidad: owned, borrowed, digital_access, to_acquire" + "possessionValues": "valores de posesión: owned, borrowed, digital_access, to_acquire" } }, "languages": { diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json index ecbf445f..1cc1c20c 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -303,7 +303,7 @@ "multiWord": "Place les valeurs multi-mots entre guillemets doubles, p. ex. author:\"Marlen Haushofer\".", "combine": "Tu peux combiner plusieurs préfixes ; les résultats doivent correspondre à tous.", "negate": "Préfixe tout terme par - pour l'exclure, p. ex. tag:cars -tag:audi.", - "availabilityValues": "valeurs de disponibilité : owned, borrowed, digital_access, to_acquire" + "possessionValues": "valeurs de possession : owned, borrowed, digital_access, to_acquire" } }, "languages": { diff --git a/frontend/src/lib/i18n/locales/zh.json b/frontend/src/lib/i18n/locales/zh.json index 8fc59cf4..d2e90505 100644 --- a/frontend/src/lib/i18n/locales/zh.json +++ b/frontend/src/lib/i18n/locales/zh.json @@ -303,7 +303,7 @@ "multiWord": "多词值请用双引号括起来,例如 author:\"Marlen Haushofer\"。", "combine": "可以组合多个前缀;结果必须满足所有条件。", "negate": "在任何词条前加 - 以将其排除,例如 tag:cars -tag:audi。", - "availabilityValues": "可用性值:owned、borrowed、digital_access、to_acquire" + "possessionValues": "持有值:owned、borrowed、digital_access、to_acquire" } }, "languages": { From 16834edc35d8c171c6d7e064202b5f6aff9762db Mon Sep 17 00:00:00 2001 From: codebude Date: Wed, 26 Aug 2026 10:12:21 +0200 Subject: [PATCH 2/3] New all books tab on library page --- docs/guide/using-librislog/library.md | 2 +- .../e2e/specs/03-library-browsing.spec.ts | 32 +++ frontend/src/lib/i18n/locales/de.json | 3 +- frontend/src/lib/i18n/locales/en.json | 3 +- frontend/src/lib/i18n/locales/es.json | 3 +- frontend/src/lib/i18n/locales/fr.json | 3 +- frontend/src/lib/i18n/locales/zh.json | 3 +- frontend/src/routes/library/+page.svelte | 44 ++-- frontend/src/routes/library/page.test.ts | 188 ++++++++++++++++++ 9 files changed, 256 insertions(+), 25 deletions(-) create mode 100644 frontend/src/routes/library/page.test.ts diff --git a/docs/guide/using-librislog/library.md b/docs/guide/using-librislog/library.md index df4962cd..a02c2c0a 100644 --- a/docs/guide/using-librislog/library.md +++ b/docs/guide/using-librislog/library.md @@ -13,7 +13,7 @@ Books are categorized into four statuses: | **Read** | Books you've finished | | **Did Not Finish** | Books you started but abandoned | -Each status has its own tab in the library view, making it easy to browse your collection by reading state. +Each status has its own tab in the library view, making it easy to browse your collection by reading state. A fifth **All Books** tab shows every book regardless of status; like the other tabs it supports search and sorting (smart sort is disabled there, since it's based on per-status defaults). ## Possession diff --git a/frontend/e2e/specs/03-library-browsing.spec.ts b/frontend/e2e/specs/03-library-browsing.spec.ts index 9b56f43b..b1735d7d 100644 --- a/frontend/e2e/specs/03-library-browsing.spec.ts +++ b/frontend/e2e/specs/03-library-browsing.spec.ts @@ -106,4 +106,36 @@ test.describe('Library Browsing', () => { await expect(page.getByText('Acquire E2E Book')).toBeVisible(); await expect(page.getByText('Owned E2E Book')).not.toBeVisible(); }); + + test('3.7 All Books tab shows every book regardless of status with search and sort', async ({ page }) => { + const library = new LibraryPage(page); + await library.goto(); + + await page.getByRole('tab', { name: /All Books/ }).click(); + await expect(page).toHaveURL(/\/library\?status=all/); + + // All 12 seeded books are shown, spanning every reading status. + await expect(library.getBookCards()).toHaveCount(12); + await expect(page.getByText('The Great Gatsby')).toBeVisible(); + await expect(page.getByText('The Three-Body Problem')).toBeVisible(); + await expect(page.getByText('1984')).toBeVisible(); + await expect(page.getByText('Atlas Shrugged')).toBeVisible(); + + // Smart sort is hidden on the All tab; the sort selects are enabled. + await expect(page.locator('input[name="smart-sort"]')).toHaveCount(0); + await expect(page.locator('select[name="sort-field"]')).toBeEnabled(); + + // Sort by title ascending: "1984" is alphabetically first among the seeds. + await page.locator('select[name="sort-field"]').selectOption('title'); + await page.locator('select[name="sort-order"]').selectOption('asc'); + await expect(page.locator('button.card h2').first()).toHaveText('1984'); + + // Search narrows the All tab results. + const searchInput = page.getByPlaceholder(/Search books/); + await searchInput.fill('Dune'); + await searchInput.press('Enter'); + await expect(page.locator('button.card')).toHaveCount(1); + await expect(page.getByText('Dune')).toBeVisible(); + await expect(page.getByText('1984')).not.toBeVisible(); + }); }); diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index e94997aa..5d4ee6e9 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -84,7 +84,8 @@ "want_to_read": "Möchte ich lesen", "currently_reading": "Lese ich gerade", "read": "Gelesen", - "did_not_finish": "Abgebrochen" + "did_not_finish": "Abgebrochen", + "all": "Alle Bücher" }, "common": { "all": "Alle", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 12f73855..1c9d0376 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -84,7 +84,8 @@ "want_to_read": "Want to Read", "currently_reading": "Currently Reading", "read": "Read", - "did_not_finish": "Did Not Finish" + "did_not_finish": "Did Not Finish", + "all": "All Books" }, "common": { "all": "All", diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index 6a8d84dc..d3bc2a6e 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -84,7 +84,8 @@ "want_to_read": "Quiero leer", "currently_reading": "Leyendo", "read": "Leído", - "did_not_finish": "Abandonado" + "did_not_finish": "Abandonado", + "all": "Todos los libros" }, "common": { "all": "Todos", diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json index 1cc1c20c..daea5c23 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -84,7 +84,8 @@ "want_to_read": "À lire", "currently_reading": "En cours", "read": "Lu", - "did_not_finish": "Abandonné" + "did_not_finish": "Abandonné", + "all": "Tous les livres" }, "common": { "all": "Tous", diff --git a/frontend/src/lib/i18n/locales/zh.json b/frontend/src/lib/i18n/locales/zh.json index d2e90505..15b1f227 100644 --- a/frontend/src/lib/i18n/locales/zh.json +++ b/frontend/src/lib/i18n/locales/zh.json @@ -84,7 +84,8 @@ "want_to_read": "想读", "currently_reading": "在读", "read": "已读", - "did_not_finish": "弃读" + "did_not_finish": "弃读", + "all": "所有书籍" }, "common": { "all": "全部", diff --git a/frontend/src/routes/library/+page.svelte b/frontend/src/routes/library/+page.svelte index 9a2c26e9..a3e3fd68 100644 --- a/frontend/src/routes/library/+page.svelte +++ b/frontend/src/routes/library/+page.svelte @@ -14,10 +14,10 @@ import AddBookModal from '$lib/components/AddBookModal.svelte'; import SearchBar from '$lib/components/SearchBar.svelte'; import SearchHelp from '$lib/components/SearchHelp.svelte'; - import { BookOpen as BookOpenIcon, Book as BookIcon, Check, X } from '@lucide/svelte'; + import { BookOpen as BookOpenIcon, Book as BookIcon, Check, Library, X } from '@lucide/svelte'; type Tab = { - status: ReadingStatus; + status: ReadingStatus | 'all'; labelKey: string; Icon: typeof BookOpenIcon; }; @@ -26,20 +26,22 @@ { status: 'want_to_read', labelKey: 'status.want_to_read', Icon: BookOpenIcon }, { status: 'currently_reading', labelKey: 'status.currently_reading', Icon: BookIcon }, { status: 'read', labelKey: 'status.read', Icon: Check }, - { status: 'did_not_finish', labelKey: 'status.did_not_finish', Icon: X } + { status: 'did_not_finish', labelKey: 'status.did_not_finish', Icon: X }, + { status: 'all', labelKey: 'status.all', Icon: Library } ]; const STATUS_LABEL_KEYS: Record = { want_to_read: 'status.want_to_read', currently_reading: 'status.currently_reading', read: 'status.read', - did_not_finish: 'status.did_not_finish' + did_not_finish: 'status.did_not_finish', + all: 'status.all' }; const PAGE_SIZE = 40; - let activeStatus = $derived( - ($page.url.searchParams.get('status') as ReadingStatus) ?? 'want_to_read' + let activeStatus = $derived( + ($page.url.searchParams.get('status') as ReadingStatus | 'all') ?? 'want_to_read' ); let requestedBookId = $derived.by(() => { const raw = $page.url.searchParams.get('bookId'); @@ -85,7 +87,7 @@ return numberFormatter.format(value); } - function getStatusCount(status: ReadingStatus): number | null { + function getStatusCount(status: ReadingStatus | 'all'): number | null { if (!statusCounts) return null; switch (status) { case 'want_to_read': @@ -96,6 +98,8 @@ return statusCounts.books_read; case 'did_not_finish': return statusCounts.books_did_not_finish; + case 'all': + return statusCounts.total_books; } } @@ -112,7 +116,7 @@ let drawerOpen = $state(false); let addBookOpen = $state(false); - function changeTab(status: ReadingStatus) { + function changeTab(status: ReadingStatus | 'all') { if (status === activeStatus) return; void goto(`/library?status=${status}`); } @@ -156,7 +160,7 @@ loading = true; try { const response = await api.books.list({ - status: activeStatus, + status: activeStatus === 'all' ? undefined : activeStatus, acquisition_status: activeStatus === 'want_to_read' && acquisitionFilter ? acquisitionFilter : undefined, q: searchQuery || undefined, smart_sort: smartSort, @@ -190,7 +194,7 @@ loadingMore = true; try { const response = await api.books.list({ - status: activeStatus, + status: activeStatus === 'all' ? undefined : activeStatus, acquisition_status: activeStatus === 'want_to_read' && acquisitionFilter ? acquisitionFilter : undefined, q: searchQuery || undefined, smart_sort: smartSort, @@ -252,7 +256,7 @@ function handleSave(updated: Book) { selectedBook = updated; - if (updated.reading_status !== activeStatus) { + if (activeStatus !== 'all' && updated.reading_status !== activeStatus) { detailOpen = false; drawerOpen = false; books = books.filter((b) => b.id !== updated.id); @@ -274,7 +278,7 @@ } function handleAdded(book: Book) { - if (book.reading_status === activeStatus) { + if (activeStatus === 'all' || book.reading_status === activeStatus) { books = [book, ...books]; } addBookOpen = false; @@ -401,18 +405,20 @@
- - + + {/if} + - @@ -479,6 +485,6 @@ diff --git a/frontend/src/routes/library/page.test.ts b/frontend/src/routes/library/page.test.ts new file mode 100644 index 00000000..ae0108b2 --- /dev/null +++ b/frontend/src/routes/library/page.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/svelte'; +import LibraryPage from './+page.svelte'; +import type { Book, LibraryStats } from '$lib/types'; + +const mockPage = vi.hoisted(() => { + const subscribers = new Set<(value: unknown) => void>(); + let state = { url: new URL('http://localhost:5173/library'), params: {}, route: { id: null } }; + + return { + subscribe(run: (value: unknown) => void) { + run(state); + subscribers.add(run); + return () => subscribers.delete(run); + }, + setUrl(url: string) { + state = { url: new URL(url), params: {}, route: { id: null } }; + subscribers.forEach((fn) => fn(state)); + } + }; +}); + +vi.mock('$app/stores', () => ({ + page: { subscribe: mockPage.subscribe }, + navigating: { subscribe: vi.fn() } +})); + +const mockGoto = vi.fn(); +vi.mock('$app/navigation', () => ({ + goto: (...args: unknown[]) => mockGoto(...args), + beforeNavigate: () => {}, + afterNavigate: () => {}, + onNavigate: () => () => {} +})); + +const mockBooksList = vi.fn(); +const mockBooksStats = vi.fn(); +const mockProgressLatest = vi.fn(); +vi.mock('$lib/api', () => ({ + api: { + books: { + list: (...args: unknown[]) => mockBooksList(...args), + stats: (...args: unknown[]) => mockBooksStats(...args), + progress: { latest: (...args: unknown[]) => mockProgressLatest(...args) } + } + } +})); + +// Stub child components so the test isolates the page's own tab/search/sort logic +// without pulling in chartjs, barcode scanners, or dialogs that break under jsdom. +function stubComponent(tag: string) { + return () => ({ + render: () => ({ html: `
`, css: { code: '', map: null }, head: '' }) + }); +} +vi.mock('$lib/components/BookCard.svelte', () => ({ default: stubComponent('BookCard') })); +vi.mock('$lib/components/BookListItem.svelte', () => ({ default: stubComponent('BookListItem') })); +vi.mock('$lib/components/BookDetailDialog.svelte', () => ({ default: stubComponent('BookDetailDialog') })); +vi.mock('$lib/components/BookDrawer.svelte', () => ({ default: stubComponent('BookDrawer') })); +vi.mock('$lib/components/AddBookModal.svelte', () => ({ default: stubComponent('AddBookModal') })); +vi.mock('$lib/components/SearchBar.svelte', () => ({ default: stubComponent('SearchBar') })); +vi.mock('$lib/components/SearchHelp.svelte', () => ({ default: stubComponent('SearchHelp') })); + +function createMockBook(id: number, overrides?: Partial): Book { + return { + id, + title: `Book ${id}`, + subtitle: null, + author: 'Test Author', + authors: ['Test Author'], + isbn: null, + cover_url: null, + publisher: null, + published_year: null, + page_count: 100, + language: null, + tags: null, + notes: null, + blurb: null, + rating: null, + reading_status: 'want_to_read', + acquisition_status: 'owned', + date_added: '2025-01-01T00:00:00Z', + date_started: null, + date_finished: null, + ...overrides + }; +} + +function createMockStats(overrides?: Partial): LibraryStats { + return { + total_books: 12, + books_want_to_read: 7, + books_reading: 1, + books_read: 3, + books_did_not_finish: 1, + ...overrides + }; +} + +describe('LibraryPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPage.setUrl('http://localhost:5173/library'); + mockBooksStats.mockResolvedValue(createMockStats()); + mockBooksList.mockResolvedValue({ total: 0, books: [] }); + mockProgressLatest.mockResolvedValue([]); + }); + + afterEach(() => { + cleanup(); + }); + + it('renders all five tabs including All Books with total count', async () => { + render(LibraryPage); + + await waitFor(() => { + expect(screen.getByRole('tab', { name: 'Want to Read (7)' })).toBeInTheDocument(); + }); + expect(screen.getByRole('tab', { name: 'Currently Reading (1)' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Read (3)' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Did Not Finish (1)' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'All Books (12)' })).toBeInTheDocument(); + }); + + it('fetches books without a status filter when status=all', async () => { + mockPage.setUrl('http://localhost:5173/library?status=all'); + mockBooksList.mockResolvedValue({ + total: 2, + books: [createMockBook(1, { reading_status: 'read' }), createMockBook(2, { reading_status: 'want_to_read' })] + }); + + render(LibraryPage); + + await waitFor(() => { + expect(mockBooksList).toHaveBeenCalled(); + }); + const params = mockBooksList.mock.calls[0][0]; + expect(params.status).toBeUndefined(); + expect(screen.getByRole('heading', { name: 'All Books' })).toBeInTheDocument(); + }); + + it('fetches books with a status filter on a status tab', async () => { + mockPage.setUrl('http://localhost:5173/library?status=want_to_read'); + + render(LibraryPage); + + await waitFor(() => { + expect(mockBooksList).toHaveBeenCalled(); + }); + const params = mockBooksList.mock.calls[0][0]; + expect(params.status).toBe('want_to_read'); + }); + + it('clicking the All Books tab navigates to status=all', async () => { + render(LibraryPage); + + const allTab = await screen.findByRole('tab', { name: /All Books/ }); + await fireEvent.click(allTab); + + expect(mockGoto).toHaveBeenCalledWith('/library?status=all'); + }); + + it('hides smart sort and keeps sort selects enabled on the All tab', async () => { + mockPage.setUrl('http://localhost:5173/library?status=all'); + + const { container } = render(LibraryPage); + + await waitFor(() => { + expect(mockBooksList).toHaveBeenCalled(); + }); + expect(container.querySelector('input[name="smart-sort"]')).toBeNull(); + const sortField = container.querySelector('select[name="sort-field"]') as HTMLSelectElement; + const sortOrder = container.querySelector('select[name="sort-order"]') as HTMLSelectElement; + expect(sortField.disabled).toBe(false); + expect(sortOrder.disabled).toBe(false); + }); + + it('shows smart sort on a status tab', async () => { + render(LibraryPage); + + const { container } = render(LibraryPage); + await waitFor(() => { + expect(mockBooksList).toHaveBeenCalled(); + }); + expect(container.querySelector('input[name="smart-sort"]')).not.toBeNull(); + }); +}); \ No newline at end of file From 411524ef0b4d3a0bb9623ea5361d324b66106b96 Mon Sep 17 00:00:00 2001 From: codebude Date: Wed, 26 Aug 2026 12:09:53 +0200 Subject: [PATCH 3/3] Add release notes to docs --- README.md | 4 +- docs/.vitepress/config.base.ts | 2 + docs/index.md | 3 + docs/releases.md | 300 +++++++++++++++++++++++++++++++++ 4 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 docs/releases.md diff --git a/README.md b/README.md index 52f90e6c..dca46a07 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@  ·  API Reference  ·  + Release Notes +  ·  Nightly Docs

@@ -128,8 +130,6 @@ MIT ## Star History -## Star History - diff --git a/docs/.vitepress/config.base.ts b/docs/.vitepress/config.base.ts index aa79461d..c4aaa661 100644 --- a/docs/.vitepress/config.base.ts +++ b/docs/.vitepress/config.base.ts @@ -40,6 +40,7 @@ export default defineConfig({ nav: [ { text: 'Guide', link: '/guide/getting-started' }, { text: 'API', link: '/api/' }, + { text: 'Releases', link: '/releases' }, { text: 'About', link: '/about' }, ], sidebar: { @@ -60,6 +61,7 @@ export default defineConfig({ ], }, { text: 'Integrations 🔗', link: '/api/integrations/' }, + { text: 'Release Notes', link: '/releases' }, ], }, { diff --git a/docs/index.md b/docs/index.md index 1cf5c247..b2656a85 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,6 +12,9 @@ hero: - theme: brand text: Get Started link: /guide/getting-started + - theme: alt + text: Release Notes + link: /releases - theme: alt text: View on GitHub link: https://github.com/codebude/librislog diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 00000000..2f2ecac5 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,300 @@ +# Release Notes + +All LibrisLog releases, newest first — what's new, what was fixed, and anything you need to know before upgrading. + +> **Upgrading:** releases are backwards compatible. Database migrations run automatically when the container starts (`alembic upgrade head`), so upgrading is a drop-in replacement. We always recommend [taking a backup](/guide/using-librislog/administration#backup-restore) before an upgrade. + +You can also browse the [GitHub Releases](https://github.com/codebude/librislog/releases) page and the [full changelog](https://github.com/codebude/librislog/commits/main). + +## vNext — Unreleased + + + +**Summary:** Everything merged into the development branch since v1.6.0, not yet released. Focused on a richer book model (multiple authors), a new search syntax, and a more flexible file import. + +**Features** +- 👥 **Multiple authors per book** — a book can have any number of authors (normalized per-user author model). The legacy API `author` field is deprecated in favor of the `authors` list +- 🔍 **Enhanced search** — field-specific prefixes (`author:`, `title:`, `publisher:`, `tag:`, `language:`, `possession:`, `notes:`, `description:`), quoted phrases, and `-` negation +- 📚 **"All books" library tab** — browse every book regardless of reading status, with the usual search and sort controls +- 📈 **Author statistics card** — total book count and distinct author count on the statistics page +- 📥 **Improved file import** + - New `authors` target field (accepts a plain string or an array; legacy `author` mappings keep working) + - **CSV delimiter** is now user-configurable (default `,`) + - **`date_added`** can be imported — preserves original library dates when migrating from other tools + - Transforms may return **lists** for the `authors`/`tags` targets (e.g. `value.split(';')`) + - Preview renders `authors` and `tags` as JSON arrays +- 📤 **Data export** — `authors` and `tags` export as lists in JSON, round-tripping through the adaptive import +- 🏷️ **Possession naming** — the acquisition/possession field and its search prefix are now consistently called **possession** + +**Bug fixes** +- 📊 Fixed the inverted Top Rated / Worst Rated ordering on the statistics page + +**Breaking changes** +- ⚠️ Creating a book now requires **at least one author** (via `authors` or the legacy `author` field) — API requests without any author are rejected +- ⚠️ The `availability:` search prefix is renamed to `possession:` (the extended search is new and unreleased, so impact is limited) + +[Compare with v1.6.0](https://github.com/codebude/librislog/compare/v1.6.0...main) + +## Latest Release + +::: tip ⭐ v1.6.0 — Reading Progress & Possession Tracking +LibrisLog v1.6.0 brings improved reading-progress tracking with automatic synchronization across cards and detail views, a new possession (book ownership) tracking model, and a range of UI, statistics, and reliability improvements. +::: + +### All releases + +| Version | Date | Type | +|---|---|---| +| [vNext](#vnext-—-unreleased) | — | Unreleased | +| [v1.6.0](#v1-6-0-—-reading-progress-possession-tracking) | 2026-08-23 | Feature release | +| [v1.5.2](#v1-5-2-—-maintenance) | 2026-06-22 | Maintenance | +| [v1.5.1](#v1-5-1-—-maintenance) | 2026-06-22 | Maintenance | +| [v1.5.0](#v1-5-0-—-password-reset-usability) | 2026-06-22 | Feature release | +| [v1.4.0](#v1-4-0-—-embeddable-views-arm64) | 2026-06-14 | Feature release | +| [v1.3.1](#v1-3-1-—-maintenance) | 2026-06-09 | Maintenance | +| [v1.3.0](#v1-3-0-—-more-languages) | 2026-06-09 | Feature release | +| [v1.2.2](#v1-2-2-—-maintenance) | 2026-06-08 | Maintenance | +| [v1.2.1](#v1-2-1-—-import-reliability-multi-user-consistency) | 2026-06-08 | Feature release | +| [v1.2.0](#v1-2-0-—-startup-screen-update-checks) | 2026-06-01 | Feature release | +| [v1.1.1](#v1-1-1-—-maintenance) | 2026-06-01 | Maintenance | +| [v1.1.0](#v1-1-0-—-polish-missing-covers) | 2026-05-31 | Feature release | +| [v1.0.0](#v1-0-0-—-initial-release) | 2026-05-28 | Initial release | + +--- + +## v1.6.0 — Reading Progress & Possession Tracking + + + +**Summary:** Improved reading-progress tracking with automatic cross-view synchronization, a new possession model for tracking what you own, and a wave of UI, statistics, and reliability improvements. + +**Features** +- 📖 Automatic synchronization of reading progress across book cards and the detail view +- 📚 Possession tracking — mark books as owned, borrowed, digitally available, or to acquire +- 📊 Possession information added to the statistics page +- 🏷️ Visual "needs to be acquired" indicators on book cards +- 📝 Improved reading-status transitions, including a start-date prompt and smarter progress-completion handling +- 📅 Timezone-aware progress charts with better visualization +- 🎨 Refined sort selector and other UI improvements +- 🔒 Updated dependencies and applied frontend security patches + +**Breaking changes:** None. + +[Full changelog](https://github.com/codebude/librislog/compare/v1.5.2...v1.6.0) + +--- + +## v1.5.2 — Maintenance + + + +**Summary:** Small maintenance release improving the accuracy of reading-progress visualizations. + +**Bug fixes** +- 📊 Fixed the fallback logic for the start date used in the reading-progress chart in the book detail view +- 🐛 Improved reliability of progress timeline calculations + +**Breaking changes:** None. + +[Full changelog](https://github.com/codebude/librislog/compare/v1.5.1...v1.5.2) + +--- + +## v1.5.1 — Maintenance + + + +**Summary:** Small maintenance release fixing an issue in the book edit drawer. + +**Bug fixes** +- 🐛 Fixed an issue where the edit drawer could fail to open correctly in certain situations + +**Breaking changes:** None. + +[Full changelog](https://github.com/codebude/librislog/compare/v1.5.0...v1.5.1) + +--- + +## v1.5.0 — Password Reset & Usability + + + +**Summary:** Adds password-reset functionality with email support, improves cover imports, and brings several usability and statistics enhancements. + +**Features** +- 🔐 Password reset via email +- 🖼️ Cover imports now follow HTTP redirects automatically +- 📱 Android back-button support for navigation drawers +- 💡 Author and publisher suggestions on the Data Hygiene page +- 📊 Reading-progress charts scaled by actual elapsed time + +**Bug fixes** +- 🐛 Various UI and usability fixes + +**Breaking changes:** None. + +[Full changelog](https://github.com/codebude/librislog/compare/v1.4.0...v1.5.0) + +--- + +## v1.4.0 — Embeddable Views & ARM64 + + + +**Summary:** Introduces embeddable library views, ARM64 Docker support, improved data-hygiene workflows, and auto-generated database documentation. + +**Features** +- 🖼️ New HTML iframe **embed endpoint** for dashboards, homepages, and other applications +- 🏗️ **ARM64 Docker images** for Raspberry Pi and other ARM-based systems +- 🧹 Improved Data Hygiene UX for incomplete or inconsistent metadata +- 📊 Integration documentation for Dashy and Glance +- 🗄️ Auto-generated database schema documentation +- 🧪 Frontend test and type improvements + +**Contributors:** Thank you to **@Jossey28** for adding ARM64 Docker build support. + +**Breaking changes:** None. + +[Full changelog](https://github.com/codebude/librislog/compare/v1.3.1...v1.4.0) + +--- + +## v1.3.1 — Maintenance + + + +**Summary:** Small maintenance release improving the reliability of update notifications. + +**Bug fixes** +- 🔔 Fixed a caching issue affecting the version update indicator + +**Breaking changes:** None. + +[Full changelog](https://github.com/codebude/librislog/compare/v1.3.0...v1.3.1) + +--- + +## v1.3.0 — More Languages + + + +**Summary:** Expands localization support, refines statistics calculations, and improves documentation. + +**Features** +- 🌍 Added **Spanish, French, and Chinese (Simplified)** UI languages with expanded localization coverage +- 📈 Improved pages-per-day statistics calculations +- 📖 Integration documentation (Dashy, Home Assistant) and general documentation improvements + +**Breaking changes:** None. + +[Full changelog](https://github.com/codebude/librislog/compare/v1.2.2...v1.3.0) + +--- + +## v1.2.2 — Maintenance + + + +**Summary:** Small maintenance release focused on Goodreads import data handling. + +**Bug fixes** +- 🐛 Fixed the Goodreads notes transformation during import processing + +**Breaking changes:** None. + +[Full changelog](https://github.com/codebude/librislog/compare/v1.2.1...v1.2.2) + +--- + +## v1.2.1 — Import Reliability & Multi-User Consistency + + + +**Summary:** Focused on import reliability, multi-user data consistency, and quality-of-life improvements. + +**Features** +- 📥 Improved Goodreads import mapping templates and book import handling +- 👥 **Per-user ISBN uniqueness** — the same ISBN can now exist for different users without conflict +- 🌐 Support for a custom documentation domain + +**Bug fixes** +- 🐛 Fixed issues affecting Goodreads imports + +**Contributors:** Thank you to **@badcrc** for their first contribution. + +**Breaking changes:** None. (The ISBN uniqueness change is a database migration and runs automatically on upgrade.) + +[Full changelog](https://github.com/codebude/librislog/compare/v1.2.0...v1.2.1) + +--- + +## v1.2.0 — Startup Screen & Update Checks + + + +**Summary:** Improves the initial user experience and adds automatic update awareness. + +**Features** +- 🚀 New startup loading screen for a smoother launch +- 🔔 **Release update check** that notifies you when a new version is available + +**Breaking changes:** None. + +[Full changelog](https://github.com/codebude/librislog/compare/v1.1.1...v1.2.0) + +--- + +## v1.1.1 — Maintenance + + + +**Summary:** Maintenance release focused on thumbnails, bug fixes, and stability. + +**Bug fixes** +- 🖼️ Improved thumbnail generation and image quality +- 🐛 Fixed several cover and thumbnail handling issues +- 🧪 Test-suite maintenance and documentation build workflow improvements + +**Breaking changes:** None. + +[Full changelog](https://github.com/codebude/librislog/compare/v1.1.0...v1.1.1) + +--- + +## v1.1.0 — Polish & Missing Covers + + + +**Summary:** A refinement release focused on usability, workflow completeness, and overall polish. + +**Features** +- 📚 Improved missing-book-cover workflow for incomplete metadata +- 🌐 Refined translations and i18n coverage +- 🧩 UX and UI improvements across the application +- 📖 Documentation updates + +**Breaking changes:** None. + +[Full changelog](https://github.com/codebude/librislog/compare/v1.0.0...v1.1.0) + +--- + +## v1.0.0 — Initial Release + + + +**Summary:** The first stable release of LibrisLog — a self-hosted, multi-user book tracking web app with full data ownership. + +**Features** +- 📚 Library management with four reading states (Want to Read, Reading, Read, Did Not Finish) +- 📖 Reading-progress tracking with per-book history +- 📊 Statistics dashboard (heatmaps, charts, reading trends) +- 📷 ISBN barcode scanning (browser-based, mobile-friendly) +- 📥 Imports from Goodreads, Open Library, Google Books, and custom CSV/JSON +- 🖼️ Automatic cover-art fetching with manual fallback +- 👥 Multi-user support with roles and optional OIDC login +- 🔌 REST API with OpenAPI documentation +- 🐳 Self-hosted via Docker Compose (SQLite, lightweight setup) +- 🎨 Light/dark themes and responsive UI + +[Full changelog](https://github.com/codebude/librislog/commits/v1.0.0) \ No newline at end of file