diff --git a/backend/app/routers/books.py b/backend/app/routers/books.py index eea30027..e33a3e72 100644 --- a/backend/app/routers/books.py +++ b/backend/app/routers/books.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status import sqlalchemy as sa from sqlalchemy.exc import IntegrityError -from sqlmodel import Session, col, func, or_, select +from sqlmodel import Session, col, func, select from app.auth import require_user from app.config import settings @@ -32,6 +32,7 @@ ) from app.services.cover_import import import_cover_from_url, is_external_cover_url from app.services.quote_cache import get_or_fetch_dashboard_quote +from app.services.search import _escape_like, apply_search_filter from app.services.tags import build_book_read, cleanup_orphan_tags, load_tags_batch, sync_book_tags from app.time_utils import utcnow @@ -141,7 +142,15 @@ def _build_book_read_with_tags(book: Book, tags_text: str | None) -> BookRead: def list_books( status: Optional[ReadingStatus] = Query(default=None), acquisition_status: Optional[AcquisitionStatus] = Query(default=None), - q: Optional[str] = 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, availability, 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)." + ), + ), has_cover: Optional[bool] = Query(default=None), sort: Literal["title", "date_added", "date_started", "date_finished", "rating"] = Query( default="date_added" @@ -172,20 +181,8 @@ def list_books( base_statement = base_statement.where(Book.acquisition_status == acquisition_status) if q: - pattern = f"%{q}%" - matching_tag_book_ids = select(BookTag.book_id).join(Tag, col(Tag.id) == BookTag.tag_id).where( - Tag.user_id == current_user.id, - col(Tag.name).ilike(pattern), - ) - base_statement = base_statement.where( - or_( - col(Book.title).ilike(pattern), - col(Book.subtitle).ilike(pattern), - col(Book.author).ilike(pattern), - col(Book.blurb).ilike(pattern), - col(Book.id).in_(matching_tag_book_ids), - ) - ) + assert current_user.id is not None + base_statement = apply_search_filter(base_statement, q, current_user.id) if has_cover is not None: if has_cover: @@ -328,17 +325,17 @@ def _suggest_field( """Return distinct values for a Book column matching the query.""" if not q.strip(): return [] - pattern = f"%{q}%" - col = getattr(Book, column) + pattern = f"%{_escape_like(q)}%" + column_expr = getattr(Book, column) rows = session.exec( - select(col) + select(column_expr) .where( Book.user_id == user_id, - col.isnot(None), - col.ilike(pattern), + column_expr.isnot(None), + column_expr.ilike(pattern, escape="\\"), ) .distinct() - .order_by(col) + .order_by(column_expr) .limit(limit) ).all() return list(rows) diff --git a/backend/app/services/search.py b/backend/app/services/search.py new file mode 100644 index 00000000..471007cd --- /dev/null +++ b/backend/app/services/search.py @@ -0,0 +1,210 @@ +"""Field-specific search query parsing and SQL filter building. + +Search queries may contain field prefixes of the form ``:value`` (or +``:"multi word value"``) to restrict a term to a single field. Any search +part may be negated by prefixing it with ``-``. Unprefixed text is collapsed +into a single phrase that is matched across the default search fields, +preserving the previous behaviour. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + +import sqlalchemy as sa +from sqlmodel import col, or_, select + +from app.models import AcquisitionStatus, Book, BookTag, Tag + +# Fields that can be targeted with a prefix. The keys are the canonical, +# always-English prefix names; the values are the book model columns. +FIELD_COLUMNS: dict[str, Any] = { + "author": Book.author, + "title": Book.title, + "publisher": Book.publisher, + "language": Book.language, + "notes": Book.notes, + "description": Book.blurb, +} + +# Availability is a special case: it maps to an exact enum comparison. +AVAILABILITY_PREFIX = "availability" +TAG_PREFIX = "tag" + +SUPPORTED_PREFIXES: frozenset[str] = frozenset( + [*FIELD_COLUMNS.keys(), AVAILABILITY_PREFIX, TAG_PREFIX] +) + +# Default fields searched by an unprefixed term (unchanged from the previous +# single-pattern search). +DEFAULT_SEARCH_COLUMNS: tuple[str, ...] = ("title", "subtitle", "author", "blurb") + +# ``:value`` — value is either a quoted string or a non-space token. +_FIELD_TERM_RE = re.compile(r"^([a-zA-Z_]+):(\"(?:\\.|[^\"\\])*\"|\S+)") +_QUOTED_PHRASE_RE = re.compile(r'^"((?:\\.|[^"\\])*)"') +_TOKEN_RE = re.compile(r"^\S+") + + +@dataclass(frozen=True) +class SearchTerm: + """A single parsed search term. + + ``field`` is ``None`` for unprefixed terms. ``negated`` indicates a leading + ``-`` on the term. + """ + + field: str | None + value: str + negated: bool = False + + +def _clean_value(raw: str) -> str: + """Strip surrounding quotes from a raw value and unescape inner quotes.""" + if len(raw) >= 2 and raw.startswith('"') and raw.endswith('"'): + raw = raw[1:-1] + elif raw.startswith('"'): + # Forgiving handling for unclosed quotes: strip the leading quote. + raw = raw[1:] + # Unescape backslashes first so an escaped quote after an escaped backslash + # is not consumed by the wrong pair. + return raw.replace("\\\\", "\\").replace('\\"', '"') + + +def _escape_like(value: str) -> str: + """Escape LIKE wildcards so user input is matched literally.""" + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + +def parse_search_query(query: str) -> list[SearchTerm]: + """Split a search query into field-specific and unprefixed terms. + + Unknown prefixes, malformed quotes, and plain tokens are kept as unprefixed + terms so the previous cross-field search behaviour is preserved. + """ + terms: list[SearchTerm] = [] + rest = query.strip() + while rest: + negated = False + if rest.startswith("-"): + negated = True + rest = rest[1:].lstrip() + if not rest: + break + + match = _FIELD_TERM_RE.match(rest) + if match: + prefix, raw_value = match.group(1), match.group(2) + if prefix.lower() in SUPPORTED_PREFIXES: + value = _clean_value(raw_value) + if value: + terms.append(SearchTerm(field=prefix.lower(), value=value, negated=negated)) + rest = rest[match.end():].lstrip() + continue + + match = _QUOTED_PHRASE_RE.match(rest) + if match: + value = _clean_value(match.group(1)) + if value: + terms.append(SearchTerm(field=None, value=value, negated=negated)) + rest = rest[match.end():].lstrip() + continue + + match = _TOKEN_RE.match(rest) + if match: + token = match.group(0) + if token: + terms.append(SearchTerm(field=None, value=token, negated=negated)) + rest = rest[match.end():].lstrip() + continue + + # Unrecognised leading character — skip it and continue. + rest = rest[1:].lstrip() + + return terms + + +def _ilike(column: Any, value: str) -> Any: + """Case-insensitive substring match that never yields NULL. + + ``NOT`` over ``LIKE`` on a NULL column produces NULL, which excludes the row. + Coalescing to false keeps NULLable fields (subtitle, blurb, publisher, …) + behaving as "no match" under negation. LIKE wildcards in the value are + escaped so user input is matched literally. + """ + escaped = _escape_like(value) + return sa.func.coalesce(col(column).ilike(f"%{escaped}%", escape="\\"), sa.false()) + + +def _tag_condition(value: str, user_id: int) -> Any: + """Return a condition matching books that have a tag containing *value*.""" + escaped = _escape_like(value) + matching_tag_book_ids = ( + select(BookTag.book_id) + .join(Tag, col(Tag.id) == BookTag.tag_id) + .where(Tag.user_id == user_id, col(Tag.name).ilike(f"%{escaped}%", escape="\\")) + ) + return col(Book.id).in_(matching_tag_book_ids) + + +def _unprefixed_condition(value: str, user_id: int) -> Any: + """Build the cross-field substring condition for an unprefixed term.""" + return or_( + *[_ilike(getattr(Book, column), value) for column in DEFAULT_SEARCH_COLUMNS], + _tag_condition(value, user_id), + ) + + +def _availability_condition(value: str) -> Any | None: + """Build the exact acquisition-status condition, or ``None`` if invalid.""" + normalized = value.strip().lower().replace(" ", "_") + try: + status = AcquisitionStatus(normalized) + except ValueError: + return None + return Book.acquisition_status == status + + +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 == TAG_PREFIX: + return _tag_condition(value, user_id) + column = FIELD_COLUMNS[field] + return _ilike(column, value) + + +def apply_search_filter(statement: Any, query: str, user_id: int) -> Any: + """Return a book SELECT statement restricted by the parsed search query. + + All terms are combined with AND. Negated terms become ``AND NOT(condition)``. + Positive unprefixed text is collapsed into a single cross-field phrase. + """ + terms = parse_search_query(query) + + conditions: list[Any] = [] + + positive_unprefixed = [term.value for term in terms if term.field is None and not term.negated] + if positive_unprefixed: + conditions.append(_unprefixed_condition(" ".join(positive_unprefixed), user_id)) + + for term in terms: + if term.field is None: + if term.negated: + conditions.append(sa.not_(_unprefixed_condition(term.value, user_id))) + continue + + 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. + conditions.append(sa.false() if not term.negated else sa.true()) + elif term.negated: + conditions.append(sa.not_(condition)) + else: + conditions.append(condition) + + if conditions: + return statement.where(sa.and_(*conditions)) + return statement \ No newline at end of file diff --git a/backend/tests/test_books.py b/backend/tests/test_books.py index 436599b6..852ff2e6 100644 --- a/backend/tests/test_books.py +++ b/backend/tests/test_books.py @@ -142,6 +142,167 @@ def test_list_books_search_by_author(client: TestClient) -> None: assert body["books"][0]["title"] == "Foundation" +def test_list_books_search_by_publisher(client: TestClient) -> None: + _create_book(client, title="Dune", publisher="Ace Books") + _create_book(client, title="Foundation", publisher="Gnome Press") + resp = client.get("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/api/books?q=publisher:gnome") + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + assert body["books"][0]["title"] == "Foundation" + + +def test_list_books_search_by_notes(client: TestClient) -> None: + _create_book(client, title="Dune", notes="spice mining") + _create_book(client, title="Foundation", notes="psychohistory") + resp = client.get("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/api/books?q=notes:spice") + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + assert body["books"][0]["title"] == "Dune" + + +def test_list_books_search_by_description(client: TestClient) -> None: + _create_book(client, title="Dune", blurb="A desert planet saga.") + _create_book(client, title="Foundation", blurb="A galactic empire collapses.") + resp = client.get("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/api/books?q=description:desert") + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + assert body["books"][0]["title"] == "Dune" + + +def test_list_books_search_by_language(client: TestClient) -> None: + _create_book(client, title="Dune", language="de") + _create_book(client, title="Foundation", language="en") + resp = client.get("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/api/books?q=language:de") + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + assert body["books"][0]["title"] == "Dune" + + +def test_list_books_search_by_availability(client: TestClient) -> None: + _create_book(client, title="Borrowed", acquisition_status="borrowed") + _create_book(client, title="Owned", acquisition_status="owned") + resp = client.get("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/api/books?q=availability: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: + _create_book(client, title="Borrowed", acquisition_status="borrowed") + resp = client.get("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/api/books?q=availability:not-a-status") + assert resp.status_code == 200 + assert resp.json()["total"] == 0 + + +def test_list_books_search_by_tag(client: TestClient) -> None: + _create_book(client, title="Dune", tags="science fiction") + _create_book(client, title="Foundation", tags="classic") + resp = client.get("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/api/books?q=tag:fiction") + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + assert body["books"][0]["title"] == "Dune" + + +def test_list_books_search_combined_field_and_unprefixed(client: TestClient) -> None: + _create_book(client, title="Die Fragezeichen", author="Christoph Dittert") + _create_book(client, title="Die Fragezeichen", author="Someone Else") + resp = client.get("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/api/books?q=fragezeichen author:Dittert") + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + assert body["books"][0]["title"] == "Die Fragezeichen" + assert body["books"][0]["author"] == "Christoph Dittert" + + +def test_list_books_search_quoted_author(client: TestClient) -> None: + _create_book(client, title="Die Fragezeichen", author="Christoph Dittert") + _create_book(client, title="Die Fragezeichen", author="Christoph Other") + resp = client.get('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/api/books?q=author:"Christoph Dittert"') + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + assert body["books"][0]["author"] == "Christoph Dittert" + + +def test_list_books_search_unknown_prefix_falls_back_to_unprefixed(client: TestClient) -> None: + _create_book(client, title="foo:bar special") + resp = client.get("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/api/books?q=foo:bar") + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + assert body["books"][0]["title"] == "foo:bar special" + + +def test_list_books_search_negated_quoted_unprefixed(client: TestClient) -> None: + _create_book(client, title="Mercedes Cars", author="A") + _create_book(client, title="Cars Only", author="B") + resp = client.get('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/api/books?q="cars" -"mercedes"') + assert resp.status_code == 200 + body = resp.json() + assert [b["title"] for b in body["books"]] == ["Cars Only"] + + +def test_list_books_search_negated_field_term(client: TestClient) -> None: + _create_book(client, title="Car Book", tags="cars") + _create_book(client, title="Audi Book", tags="cars,audi") + _create_book(client, title="Audi Only", tags="audi") + resp = client.get("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/api/books?q=tag:cars%20-tag:audi") + assert resp.status_code == 200 + body = resp.json() + assert [b["title"] for b in body["books"]] == ["Car Book"] + + +def test_list_books_search_negated_only(client: TestClient) -> None: + _create_book(client, title="Audi Book", tags="audi") + _create_book(client, title="Plain Book", tags="other") + resp = client.get("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/api/books?q=-tag:audi") + assert resp.status_code == 200 + body = resp.json() + assert [b["title"] for b in body["books"]] == ["Plain Book"] + + +def test_list_books_search_lone_dash_returns_all(client: TestClient) -> None: + _create_book(client, title="One") + _create_book(client, title="Two") + resp = client.get("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/api/books?q=-") + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 2 + + +def test_list_books_search_percent_is_literal(client: TestClient) -> None: + _create_book(client, title="100% Pure") + _create_book(client, title="100 Miles") + resp = client.get("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/api/books?q=title:100%25") + assert resp.status_code == 200 + body = resp.json() + assert [b["title"] for b in body["books"]] == ["100% Pure"] + + +def test_list_books_search_negation_includes_nullable_field_rows(client: TestClient) -> None: + _create_book(client, title="Plain") + _create_book(client, title="Mercedes") + resp = client.get("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/api/books?q=-mercedes") + assert resp.status_code == 200 + body = resp.json() + assert [b["title"] for b in body["books"]] == ["Plain"] + + +def test_list_books_search_availability_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('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/api/books?q=availability:"to acquire"') + assert resp.status_code == 200 + body = resp.json() + assert [b["title"] for b in body["books"]] == ["Wanted"] + + def test_list_books_sort_by_rating(client: TestClient) -> None: _create_book(client, title="Low", rating=2) _create_book(client, title="High", rating=5) diff --git a/backend/tests/test_search_query.py b/backend/tests/test_search_query.py new file mode 100644 index 00000000..ad4af3c9 --- /dev/null +++ b/backend/tests/test_search_query.py @@ -0,0 +1,115 @@ +"""Unit tests for the field-specific search query parser.""" + +from app.services.search import parse_search_query + + +def _terms(query: str) -> list[tuple[str | None, str, bool]]: + return [(t.field, t.value, t.negated) for t in parse_search_query(query)] + + +def test_parse_unprefixed_only() -> None: + assert _terms("dune") == [(None, "dune", False)] + + +def test_parse_single_field() -> None: + assert _terms("author:Marlen") == [("author", "Marlen", False)] + + +def test_parse_quoted_multiword() -> None: + assert _terms('author:"Marlen Haushofer"') == [("author", "Marlen Haushofer", False)] + + +def test_parse_multiple_fields() -> None: + assert _terms("author:Dittert title:fragezeichen") == [ + ("author", "Dittert", False), + ("title", "fragezeichen", False), + ] + + +def test_parse_mixed_prefixed_and_unprefixed() -> None: + assert _terms("fragezeichen author:Dittert") == [ + (None, "fragezeichen", False), + ("author", "Dittert", False), + ] + + +def test_parse_unknown_prefix_kept_in_unprefixed() -> None: + assert _terms("foo:bar") == [(None, "foo:bar", False)] + + +def test_parse_unclosed_quote() -> None: + assert _terms('author:"Marlen') == [("author", "Marlen", False)] + + +def test_parse_empty_quoted_value() -> None: + assert _terms('author:""') == [] + + +def test_parse_case_insensitive_prefix() -> None: + assert _terms("Author:Marlen") == [("author", "Marlen", False)] + assert _terms("TITLE:dune") == [("title", "dune", False)] + + +def test_parse_escaped_quote() -> None: + assert _terms(r'author:"O\"Brian"') == [("author", 'O"Brian', False)] + + +def test_parse_negated_field_term() -> None: + assert _terms("-tag:audi") == [("tag", "audi", True)] + + +def test_parse_negated_quoted_unprefixed() -> None: + assert _terms('"cars" -"mercedes benz"') == [ + (None, "cars", False), + (None, "mercedes benz", True), + ] + + +def test_parse_negated_single_unprefixed() -> None: + assert _terms("-cars") == [(None, "cars", True)] + + +def test_parse_mixed_positive_and_negated() -> None: + assert _terms("tag:cars -tag:audi") == [ + ("tag", "cars", False), + ("tag", "audi", True), + ] + + +def test_parse_lone_negation() -> None: + assert _terms("-") == [] + + +def test_parse_bare_prefix() -> None: + # A bare prefix without a value is treated as literal unprefixed text. + assert _terms("author:") == [(None, "author:", False)] + + +def test_parse_all_supported_prefixes() -> None: + query = "author:a title:t publisher:p tag:g language:en availability:owned notes:n description:d" + fields = [t.field for t in parse_search_query(query)] + assert fields == [ + "author", + "title", + "publisher", + "tag", + "language", + "availability", + "notes", + "description", + ] + + +def test_availability_condition_accepts_enum_values() -> None: + from app.services.search import _availability_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 + + +def test_availability_condition_rejects_unknown_value() -> None: + from app.services.search import _availability_condition + + assert _availability_condition("not-a-status") is None \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 3c2f0221..8b4d093d 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,7 +1,8 @@ services: backend: build: - context: ./backend + context: . + dockerfile: ./backend/Dockerfile args: APP_VERSION: ${APP_VERSION:-v0.0.0-dev} GIT_SHA: ${GIT_SHA:-unknown} diff --git a/docs/.vitepress/config.base.ts b/docs/.vitepress/config.base.ts index a0d94eac..26cad6ca 100644 --- a/docs/.vitepress/config.base.ts +++ b/docs/.vitepress/config.base.ts @@ -61,6 +61,7 @@ export default defineConfig({ items: [ { text: 'Dashboard', link: '/guide/using-librislog/dashboard' }, { text: 'Library', link: '/guide/using-librislog/library' }, + { text: 'Search', link: '/guide/using-librislog/search' }, { text: 'Profile', link: '/guide/using-librislog/profile' }, { text: 'Progress Tracking', link: '/guide/using-librislog/progress' }, { text: 'Statistics', link: '/guide/using-librislog/statistics' }, diff --git a/docs/about.md b/docs/about.md index 0b213aa8..ada16ccf 100644 --- a/docs/about.md +++ b/docs/about.md @@ -5,6 +5,7 @@ LibrisLog is a **multi-user book tracking web application** designed for readers ## Features - **Library Management**: Organize books into four reading statuses — Want to Read, Currently Reading, Read, and Did Not Finish +- **Advanced Search**: Find books by title, author, tags, publisher, language, availability, notes, and description using field prefixes and negation - **Book Import**: Search Open Library, Google Books, and Hardcover.app. Scan ISBN barcodes for quick lookup - **Reading Progress**: Track pages read over time with a visual timeline and calendar heatmap - **Statistics Dashboard**: Charts showing pages read per month, books finished, language distribution, and more diff --git a/docs/guide/using-librislog/dashboard.md b/docs/guide/using-librislog/dashboard.md index 2be01dd0..008909cb 100644 --- a/docs/guide/using-librislog/dashboard.md +++ b/docs/guide/using-librislog/dashboard.md @@ -6,7 +6,9 @@ The dashboard is the first page you see after logging in. It gives you an overvi ## Search -The search bar at the top of the dashboard lets you find books by title, author, or tags. The result count updates as you type and matching books appear in a dropdown below the bar. +The search bar at the top of the dashboard lets you find books by title, author, or tags. It also supports field-specific queries such as `author:Murakami` and negation such as `Haushofer -"Die Wand"`. See the [search syntax reference](/guide/using-librislog/search) for the full list of supported prefixes and examples. + +The result count updates as you type and matching books appear in a dropdown below the bar. - **Arrow keys** to navigate the dropdown - **Enter** opens the selected book's detail view; if no item is selected, it navigates to the dedicated search results page (`/search`) showing all matches diff --git a/docs/guide/using-librislog/library.md b/docs/guide/using-librislog/library.md index f8a0de29..76436e34 100644 --- a/docs/guide/using-librislog/library.md +++ b/docs/guide/using-librislog/library.md @@ -95,6 +95,7 @@ Downloaded covers are cached locally in the `COVERS_DIR` directory to avoid repe ## Search - Search books by title, author, or tags using the search bar — the result count updates as you type +- Field-specific queries are supported, e.g. `author:Murakami`, `availability:owned`, or `tag:fantasy`. See the [search syntax reference](/guide/using-librislog/search) for the full list of prefixes and examples. - Press **Enter** to open the dedicated search results page with a full results grid, load-more pagination, and the same book detail interaction as the library - From any page, navigate directly to `/search?q=your+query` for quick access diff --git a/docs/guide/using-librislog/search.md b/docs/guide/using-librislog/search.md new file mode 100644 index 00000000..6b457843 --- /dev/null +++ b/docs/guide/using-librislog/search.md @@ -0,0 +1,57 @@ +# Search + +The search box on the **Dashboard**, **Library**, and dedicated `/search` page supports plain-text matching as well as field-specific and negated queries. + +## Field prefixes + +Use `:` to search in a single field. The field prefixes are always in **English**, regardless of the UI language. + +| Prefix | Field | Example | +|--------|-------|---------| +| `author` | Author(s) | `author:Murakami` | +| `title` | Title | `title:"The Hobbit"` | +| `publisher` | Publisher | `publisher:Penguin` | +| `language` | Language | `language:Japanese` | +| `tag` | Tag name | `tag:fantasy` | +| `availability` | Acquisition status | `availability:owned` | +| `notes` | Private notes | `notes:"to reread"` | +| `description` | Blurb / description | `description:"middle earth"` | + +Use quotes for values that contain spaces: `title:"The Silmarillion"`. + +### Availability values + +The `availability` prefix matches the exact acquisition status. Accepted values include: + +- `to_acquire` (or `to acquire`) +- `owned` +- `borrowed` +- `digital` + +Example: `availability:"to acquire"` shows books you want to buy. + +## Negation + +Prefix a term with `-` to exclude matches. + +- `-author:Rowling` +- `-tag:horror` +- `Haushofer -"Die Wand"` + +## Combining terms + +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 + +## Plain text + +An unprefixed phrase searches across title, author, publisher, language, notes, description, and tags. It is matched as a phrase, not as individual words. + +- `Marlen Haushofer` — matches the exact phrase across the supported fields +- `Haushofer -Wand` — matches "Haushofer" but excludes books whose fields contain "Wand" + +## Quick reference in the app + +Click the **?** icon next to any search input to open a quick-reference card with the available prefixes and examples. diff --git a/frontend/e2e/fixtures/seed-data.ts b/frontend/e2e/fixtures/seed-data.ts index bf5f28a9..a950a6dd 100644 --- a/frontend/e2e/fixtures/seed-data.ts +++ b/frontend/e2e/fixtures/seed-data.ts @@ -27,4 +27,7 @@ export const SEED_BOOKS: SeedBook[] = [ { title: '1984', author: 'George Orwell', isbn: '9780451524935', reading_status: 'read', rating: 5, page_count: 328, date_started: '2024-10-01', date_finished: '2024-10-20' }, { title: 'Brave New World', author: 'Aldous Huxley', isbn: '9780060850524', reading_status: 'read', rating: 4, page_count: 311, date_started: '2024-09-01', date_finished: '2024-09-18' }, { title: 'Atlas Shrugged', author: 'Ayn Rand', reading_status: 'did_not_finish', page_count: 1168 }, + { title: 'Die Fragezeichen', author: 'Christoph Dittert', reading_status: 'want_to_read' }, + { title: 'Cars & Mercedes', author: 'Jane Driver', reading_status: 'want_to_read', tags: 'cars,audi' }, + { title: 'Cars Only', author: 'Jane Driver', reading_status: 'want_to_read', tags: 'cars' }, ]; diff --git a/frontend/e2e/specs/02-dashboard.spec.ts b/frontend/e2e/specs/02-dashboard.spec.ts index 12d20aa7..a18f167f 100644 --- a/frontend/e2e/specs/02-dashboard.spec.ts +++ b/frontend/e2e/specs/02-dashboard.spec.ts @@ -47,6 +47,21 @@ test.describe('Dashboard', () => { await expect(page.locator('body')).toContainText(/The Great Gatsby/i); }); + test('2.7 prefixed dashboard search navigates to filtered search page', async ({ page }) => { + await seedBooks(page, SEED_BOOKS); + await page.reload(); + await page.waitForSelector('h1'); + + const searchInput = page.locator('input[type="text"]'); + await searchInput.fill('author:Dittert'); + await page.waitForTimeout(1000); + + await searchInput.press('Enter'); + await expect(page).toHaveURL(/\/search\?q=author%3ADittert/); + await page.waitForTimeout(1000); + await expect(page.locator('body')).toContainText(/Die Fragezeichen/i); + }); + test('2.6 arrow key navigation in dropdown opens book detail dialog on Enter', async ({ page }) => { await seedBooks(page, SEED_BOOKS); await page.reload(); diff --git a/frontend/e2e/specs/04-search-page.spec.ts b/frontend/e2e/specs/04-search-page.spec.ts index b32ba357..8be304fa 100644 --- a/frontend/e2e/specs/04-search-page.spec.ts +++ b/frontend/e2e/specs/04-search-page.spec.ts @@ -77,4 +77,28 @@ test.describe('Search Page', () => { await expect(page).toHaveURL('/dashboard'); }); + + test('4.7 field-prefixed search filters by author', async ({ page }) => { + await page.goto('/search?q=author:Dittert'); + await page.waitForTimeout(1500); + + await expect(page.locator('body')).toContainText(/Die Fragezeichen/i); + await expect(page.locator('body')).not.toContainText(/The Great Gatsby/i); + }); + + test('4.8 negated quoted phrase excludes matching books', async ({ page }) => { + await page.goto('/search?q=%22cars%22%20-%22mercedes%22'); + await page.waitForTimeout(1500); + + await expect(page.locator('body')).toContainText(/Cars Only/i); + await expect(page.locator('body')).not.toContainText(/Cars & Mercedes/i); + }); + + test('4.9 negated tag excludes books tagged audi', async ({ page }) => { + await page.goto('/search?q=tag%3Acars%20-tag%3Aaudi'); + await page.waitForTimeout(1500); + + await expect(page.locator('body')).toContainText(/Cars Only/i); + await expect(page.locator('body')).not.toContainText(/Cars & Mercedes/i); + }); }); diff --git a/frontend/src/lib/components/SearchHelp.svelte b/frontend/src/lib/components/SearchHelp.svelte new file mode 100644 index 00000000..fc56fce3 --- /dev/null +++ b/frontend/src/lib/components/SearchHelp.svelte @@ -0,0 +1,81 @@ + + +
+ + + {#if open} + + + {/if} +
\ No newline at end of file diff --git a/frontend/src/lib/components/SearchHelp.test.ts b/frontend/src/lib/components/SearchHelp.test.ts new file mode 100644 index 00000000..1c319479 --- /dev/null +++ b/frontend/src/lib/components/SearchHelp.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/svelte'; +import SearchHelp from './SearchHelp.svelte'; + +describe('SearchHelp', () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('renders the help trigger button', () => { + render(SearchHelp); + expect(screen.getByRole('button', { name: 'Search syntax' })).toBeInTheDocument(); + }); + + it('opens the help panel on click', async () => { + render(SearchHelp); + const trigger = screen.getByRole('button', { name: 'Search syntax' }); + + await fireEvent.click(trigger); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Use field prefixes to search in a specific field. Prefixes are always English.')).toBeInTheDocument(); + }); + + it('displays the hardcoded English prefixes', async () => { + render(SearchHelp); + await fireEvent.click(screen.getByRole('button', { name: 'Search syntax' })); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveTextContent('author'); + expect(dialog).toHaveTextContent('publisher'); + expect(dialog).toHaveTextContent('title'); + expect(dialog).toHaveTextContent('tag'); + expect(dialog).toHaveTextContent('language'); + expect(dialog).toHaveTextContent('availability'); + expect(dialog).toHaveTextContent('notes'); + expect(dialog).toHaveTextContent('description'); + }); + + it('displays the quoted-value and negation examples', async () => { + render(SearchHelp); + await fireEvent.click(screen.getByRole('button', { name: 'Search syntax' })); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveTextContent('author:"Christoph Dittert"'); + expect(dialog).toHaveTextContent('tag:cars -tag:audi'); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 520c3bdd..dcbefd50 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -291,7 +291,15 @@ "resultsCount": "{count, plural, one {Ergebnis} other {Ergebnisse}} gefunden", "noResults": "Keine Ergebnisse gefunden", "noResultsFor": "Keine Ergebnisse für \"{query}\" gefunden", - "tryDifferentQuery": "Versuche einen anderen Suchbegriff" + "tryDifferentQuery": "Versuche einen anderen Suchbegriff", + "help": { + "title": "Suchsyntax", + "intro": "Verwende Feld-Präfixe, um in einem bestimmten Feld zu suchen. Präfixe sind immer auf Englisch.", + "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" + } }, "languages": { "en": "Englisch", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 163ce136..825c80d8 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -291,7 +291,15 @@ "resultsCount": "{count, plural, one {result} other {results}} found", "noResults": "No results found", "noResultsFor": "No results found for \"{query}\"", - "tryDifferentQuery": "Try a different search term" + "tryDifferentQuery": "Try a different search term", + "help": { + "title": "Search syntax", + "intro": "Use field prefixes to search in a specific field. Prefixes are always English.", + "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" + } }, "languages": { "en": "English", diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index 9cd2edcc..3d1c9736 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -291,7 +291,15 @@ "resultsCount": "{count, plural, one {resultado} other {resultados}} encontrados", "noResults": "No se encontraron resultados", "noResultsFor": "No se encontraron resultados para \"{query}\"", - "tryDifferentQuery": "Prueba con un término de búsqueda diferente" + "tryDifferentQuery": "Prueba con un término de búsqueda diferente", + "help": { + "title": "Sintaxis de búsqueda", + "intro": "Usa prefijos de campo para buscar en un campo concreto. Los prefijos siempre están en inglés.", + "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" + } }, "languages": { "en": "Inglés", diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json index f66d0537..d2628c02 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -291,7 +291,15 @@ "resultsCount": "{count, plural, one {résultat} other {résultats}} trouvés", "noResults": "Aucun résultat trouvé", "noResultsFor": "Aucun résultat trouvé pour \"{query}\"", - "tryDifferentQuery": "Essaie un autre terme de recherche" + "tryDifferentQuery": "Essaie un autre terme de recherche", + "help": { + "title": "Syntaxe de recherche", + "intro": "Utilise des préfixes de champ pour rechercher dans un champ précis. Les préfixes sont toujours en anglais.", + "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" + } }, "languages": { "en": "Anglais", diff --git a/frontend/src/lib/i18n/locales/zh.json b/frontend/src/lib/i18n/locales/zh.json index c9c42e21..7261ad6d 100644 --- a/frontend/src/lib/i18n/locales/zh.json +++ b/frontend/src/lib/i18n/locales/zh.json @@ -291,7 +291,15 @@ "resultsCount": "找到 {count} 个结果", "noResults": "未找到结果", "noResultsFor": "未找到 \"{query}\" 的结果", - "tryDifferentQuery": "尝试其他搜索词" + "tryDifferentQuery": "尝试其他搜索词", + "help": { + "title": "搜索语法", + "intro": "使用字段前缀在特定字段中搜索。前缀始终为英文。", + "multiWord": "多词值请用双引号括起来,例如 author:\"Marlen Haushofer\"。", + "combine": "可以组合多个前缀;结果必须满足所有条件。", + "negate": "在任何词条前加 - 以将其排除,例如 tag:cars -tag:audi。", + "availabilityValues": "可用性值:owned、borrowed、digital_access、to_acquire" + } }, "languages": { "en": "英语", diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index 14cc8b14..0162f57d 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -11,6 +11,7 @@ import BookCard from '$lib/components/BookCard.svelte'; import BookDetailDialog from '$lib/components/BookDetailDialog.svelte'; import BookDrawer from '$lib/components/BookDrawer.svelte'; + import SearchHelp from '$lib/components/SearchHelp.svelte'; import { Search, X } from '@lucide/svelte'; let loading = $state(true); @@ -311,8 +312,9 @@ import { Search, X } from '@lucide/svelte';

{$_('dashboard.searchAllBooks')}

-
- +
+
+ {/if} +
+
diff --git a/frontend/src/routes/library/+page.svelte b/frontend/src/routes/library/+page.svelte index f664d2c4..9a2c26e9 100644 --- a/frontend/src/routes/library/+page.svelte +++ b/frontend/src/routes/library/+page.svelte @@ -13,6 +13,7 @@ import BookDrawer from '$lib/components/BookDrawer.svelte'; 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'; type Tab = { @@ -347,6 +348,7 @@ placeholder={$_('common.searchBooks')} onSearch={(q) => (searchQuery = q)} /> + {#if searchQuery} {totalCount} {totalCount === 1 ? $_('common.result') : $_('common.results')} diff --git a/frontend/src/routes/search/+page.svelte b/frontend/src/routes/search/+page.svelte index cb1239a2..879e19e5 100644 --- a/frontend/src/routes/search/+page.svelte +++ b/frontend/src/routes/search/+page.svelte @@ -10,6 +10,7 @@ import BookCard from '$lib/components/BookCard.svelte'; import BookDetailDialog from '$lib/components/BookDetailDialog.svelte'; import BookDrawer from '$lib/components/BookDrawer.svelte'; + import SearchHelp from '$lib/components/SearchHelp.svelte'; import { Search, ArrowLeft, X } from '@lucide/svelte'; const PAGE_SIZE = 40; @@ -216,6 +217,8 @@ {/if}
+ + diff --git a/frontend/src/routes/search/page.test.ts b/frontend/src/routes/search/page.test.ts index 0725cdf5..038a4d2e 100644 --- a/frontend/src/routes/search/page.test.ts +++ b/frontend/src/routes/search/page.test.ts @@ -99,6 +99,24 @@ describe('SearchPage', () => { }); }); + it('passes prefixed query from URL to the API unchanged', async () => { + mockPage.setUrl('http://localhost:5173/search?q=author%3A%22Marlen%20Haushofer%22%20-tag%3Acars'); + + mockBooksList.mockResolvedValue({ total: 0, books: [] }); + + render(SearchPage); + + await waitFor(() => { + expect(mockBooksList).toHaveBeenCalledWith( + expect.objectContaining({ + q: 'author:"Marlen Haushofer" -tag:cars', + offset: 0, + limit: 40 + }) + ); + }); + }); + it('displays search results', async () => { mockPage.setUrl('http://localhost:5173/search?q=Dune');