Author SHA1 Message Date
patrick 412a73cedf fix: tell the tests where the database actually is
ci / backend (push) Failing after 1m16s
ci / frontend (push) Successful in 1m58s
DOCKER_HOST only decides which daemon creates the container; the address the
test dials comes from POSTGRES_HOST, which defaults to the job container's own
loopback. Setting one without the other still times out.
2026-08-18 00:49:41 -04:00
patrick 5176dfcb77 fix: give the backend CI job a docker daemon it can address
ci / backend (push) Failing after 1m30s
ci / frontend (push) Successful in 1m58s
The runner has docker, so the database container started; the job just could
not reach it. pytest-databases takes the address from DOCKER_HOST, and unset
means 127.0.0.1 -- the job container's loopback, not the namespace the sibling
published on. A dind service makes it resolve to a host that answers.
2026-08-18 00:45:43 -04:00
patrick b85ba92380 fix: declare ruff as a dev dependency
ci / backend (push) Failing after 1m7s
ci / frontend (push) Successful in 1m59s
CI has no nix shell, and ruff was only ever a nix package, so both lint steps
died with "Failed to spawn: ruff". Pinned exactly: the formatter deciding what
is correct must be one version, or CI rejects what the dev shell produced.
2026-08-18 00:37:00 -04:00
patrick e9fe18266d feat: lint and format tests in CI
ci / backend (push) Failing after 36s
ci / frontend (push) Successful in 3m26s
Only src/ was covered, which is why it stayed clean while tests/ drifted to 38
findings. migrations/ stays out: its alembic template emits unused imports, so
every generated revision would fail the gate.
2026-08-17 20:56:24 -04:00
patrick 5ec5a4d334 chore: bring the test suite up to ruff's standards
Mostly automatic: empty f-strings, unused imports, formatting. The manual half
was duplicated import blocks stranded mid-file and `== None` assertions, which
become `is None` here because they compare plain attributes -- unlike the
identical rule in filters/book.py, where they build SQL.

Two unused variables are the tests never checking the disk in either
delete_files direction. Left in place under a noqa so the gap stays visible.
2026-08-17 20:56:24 -04:00
patrick 0c25f63600 feat: block CI on the frontend type check
svelte-check is clean, so it gates like the rest. eslint still reports without
failing, now for two findings rather than 87: both are the unsanitized book
description, written up in TODO.md.
2026-08-17 17:59:06 -04:00
patrick 64fed8671e fix: clear the last type errors
asChild is a bits-ui v1 prop the icon picker no longer needs -- it already
passes a child snippet. The drop zone set both webkitdirectory and a bare
directory attribute, which no browser implements. The library view seeds its
collection state once by design, so it says so with untrack.
2026-08-17 17:57:46 -04:00
patrick 3a29294f96 chore: clear the mechanical lint and type findings
Dead imports and locals removed, each blocks keyed, `any` narrowed to unknown.
Two context setters kept their calls and lost only the unused binding; the
settings redirect no longer awaits a parent whose data it discards. A leading
underscore now marks a binding that only holds a position.
2026-08-17 17:55:21 -04:00
patrick 428168c07a chore: stop lint rules that do not model runes from reporting
no-unused-expressions is off for Svelte files: a bare `book;` in an $effect
declares a dependency. The URL and Set instances it flagged are local
temporaries, marked individually. The one real finding was visibleApiKeys,
genuine reactive state cloned on every mutation to force an update; it is a
SvelteSet now.
2026-08-17 17:42:34 -04:00
patrick fbe8a8bf21 fix: type the proxy handlers and the delete callback
The RequestHandler import sat commented out while all four handlers were
annotated with it, so every destructured parameter was an implicit any --
24 of the 30 svelte-check errors. deleteFn returned `{}`, which void is not
assignable to.
2026-08-17 17:42:34 -04:00
patrick 157cc60e91 chore: update lucide icons, prettier-plugin-svelte and jsrepo
The prettier plugin's major reflows one attribute in textarea.svelte.
2026-08-17 16:32:08 -04:00
patrick 930b222b28 chore: update to TypeScript 6
TypeScript 7 is held back: svelte-check refuses it unless 6 and 7 are both
installed behind an npm alias and driven with its experimental --tsgo flag.
2026-08-17 16:29:38 -04:00
patrick 01cdd95bc7 chore: update to Vite 8
Swaps the bundler to rolldown, so chunk layout changes but output does not.
Carries @sveltejs/vite-plugin-svelte 7, which is the release built for it.
2026-08-17 16:27:38 -04:00
66 changed files with 1128 additions and 1302 deletions
+31 -9
View File
@@ -2,10 +2,11 @@ name: ci
# Formatting, linting, types and tests on every push and pull request. # Formatting, linting, types and tests on every push and pull request.
# #
# Blocking: the checks that are clean today — ruff format, ruff check, prettier, pytest. # Blocking: ruff format, ruff check, pytest, prettier and svelte-check.
# Non-blocking: eslint and svelte-check, which still report 87 and 30 pre-existing errors. # Non-blocking: eslint, which reports two `{@html}` XSS findings in collapsible-text.svelte.
# Those need real code changes rather than a formatter, so they report without failing the # Those are a real vulnerability rather than a lint nit — book descriptions from EPUB files
# build; drop the `continue-on-error` line from a step once its count reaches zero. # are not sanitized — and fixing them is a backend change. Drop the `continue-on-error` once
# that lands, at which point every check blocks.
# #
# The release workflow runs the blocking half again before it publishes anything. # The release workflow runs the blocking half again before it publishes anything.
@@ -18,6 +19,30 @@ jobs:
backend: backend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# pytest-databases starts PostgreSQL in a container and then connects to it, and those are
# two different addresses that have to be set separately:
#
# DOCKER_HOST which daemon to create the container on (_service.py get_docker_host)
# POSTGRES_HOST where the test then connects (docker/postgres.py, default
# 127.0.0.1 -- the job container's own loopback, where nothing listens,
# because the database is a sibling container on another namespace)
#
# Setting only the first leaves the tests dialling 127.0.0.1 and timing out with
# "Service 'pytest_databases_postgres' failed to come online".
#
# If the runner is ever given its own dind sidecar, drop this services block and keep the
# two env vars pointed at whatever host it exposes.
services:
docker:
image: docker:27-dind
options: --privileged
env:
DOCKER_TLS_CERTDIR: ''
env:
DOCKER_HOST: tcp://docker:2375
POSTGRES_HOST: docker
defaults: defaults:
run: run:
working-directory: backend working-directory: backend
@@ -36,13 +61,11 @@ jobs:
run: uv sync --locked run: uv sync --locked
- name: Format - name: Format
run: uv run ruff format --check src/ run: uv run ruff format --check src/ tests/
- name: Lint - name: Lint
run: uv run ruff check src/ run: uv run ruff check src/ tests/
# pytest-databases starts a throwaway PostgreSQL container, so this needs a working
# Docker daemon on the runner — the same requirement the release workflow has.
- name: Tests - name: Tests
run: uv run pytest tests/ -q run: uv run pytest tests/ -q
@@ -77,4 +100,3 @@ jobs:
- name: Types - name: Types
run: pnpm check run: pnpm check
continue-on-error: true
+4 -3
View File
@@ -42,20 +42,21 @@ jobs:
working-directory: backend working-directory: backend
run: | run: |
uv sync --locked uv sync --locked
uv run ruff format --check src/ uv run ruff format --check src/ tests/
uv run ruff check src/ uv run ruff check src/ tests/
uv run pytest tests/ -q uv run pytest tests/ -q
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 24
- name: Frontend format - name: Frontend format and types
working-directory: frontend working-directory: frontend
run: | run: |
corepack enable corepack enable
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
pnpm exec prettier --check . pnpm exec prettier --check .
pnpm check
build: build:
needs: quality needs: quality
+6 -4
View File
@@ -70,7 +70,7 @@ Backend (from `backend/`):
```bash ```bash
uv run litestar --app-dir src/chitai/ run --reload # dev server on :8000 uv run litestar --app-dir src/chitai/ run --reload # dev server on :8000
pytest tests/ # needs Docker (pytest-databases) pytest tests/ # needs Docker (pytest-databases)
ruff format src/ ruff format src/ tests/
alchemy --config chitai.database.config.config make-migrations alchemy --config chitai.database.config.config make-migrations
alchemy --config chitai.database.config.config upgrade alchemy --config chitai.database.config.config upgrade
@@ -103,9 +103,11 @@ API docs are served by the running backend at `http://localhost:8000/schema/` (S
manually against a live backend). manually against a live backend).
- **Migrations are mandatory.** The app runs with `create_all=False`, so a model change without a - **Migrations are mandatory.** The app runs with `create_all=False`, so a model change without a
matching Alembic revision will not reach the database. matching Alembic revision will not reach the database.
- **CI gates formatting, linting and tests.** `.gitea/workflows/ci.yml` runs on every push and pull - **CI gates formatting, linting, types and tests.** `.gitea/workflows/ci.yml` runs on every push
request: `ruff format --check`, `ruff check`, `pytest`, and `prettier --check` all **block**; and pull request: `ruff format --check src/ tests/`, `ruff check src/ tests/`, `pytest`,
`eslint` and `pnpm check` report without failing until their pre-existing counts reach zero. A `prettier --check` and `pnpm check` all **block**; only `eslint` reports without failing, and
only until the two `{@html}` findings in `TODO.md` are fixed. Ruff covers `src/` and `tests/`
but deliberately not `migrations/`, whose alembic template emits imports it does not use. A
`v*` tag additionally builds and publishes both container images — see `v*` tag additionally builds and publishes both container images — see
`docs/ci-release-pipeline.md`. `docs/ci-release-pipeline.md`.
- **Commit messages** follow `type: summary``feat:`, `fix:`, `refactor:`, `chore:`. - **Commit messages** follow `type: summary``feat:`, `fix:`, `refactor:`, `chore:`.
+47
View File
@@ -114,6 +114,24 @@ CMD ["litestar", "--app-dir", "chitai", "run", "--host", "0.0.0.0", "--port", "8
`litestar run` is the CLI development runner. Production should invoke uvicorn or granian `litestar run` is the CLI development runner. Production should invoke uvicorn or granian
directly, with a worker count. directly, with a worker count.
### The delete_files flag is untested in both directions
`backend/tests/integration/test_book.py``test_remove_file_with_delete_files_false_keeps_filesystem_file`
and `test_remove_file_with_delete_files_true_removes_filesystem_file`
Both tests capture the file's path and then assert only `response.status_code == 204`.
Neither looks at the disk. So the flag that decides whether removing a file from a book
also **erases it from the filesystem** is covered in name only, in both directions.
Ruff surfaced this as two `F841` unused variables; the variables carry a `# noqa: F841`
and a comment rather than being deleted, so the gap stays visible. Remove the noqa when
the assertions land.
The reason it is not a two-line fix: `FileMetadata.path` is stored relative to `book.path`,
so the test has to resolve it against the library root to know what to stat. That
resolution is the same thing `BookService.get_files` is recorded as getting wrong (see
`backend/AGENTS.md`), so it is worth settling once and using in both places.
### No type checker on the backend ### No type checker on the backend
Formatting, linting and tests now run in CI (`.gitea/workflows/ci.yml`) and block, and the Formatting, linting and tests now run in CI (`.gitea/workflows/ci.yml`) and block, and the
@@ -126,6 +144,35 @@ that set is worthwhile and will surface a fresh batch of findings.
## Frontend ## Frontend
### Book descriptions are rendered as unsanitized HTML
`frontend/src/lib/components/ui/collapsible-text/collapsible-text.svelte` — lines 42 and 45
The component renders `{@html text}`, and its only caller is the book detail page:
`<CollapsibleText text={book.description} maxLength={500} />`. So whatever is in
`Book.description` reaches the DOM as markup.
The Calibre importer is fine — `services/calibre.py:401` passes comments through
`strip_html`, because Calibre stores HTML there. But `strip_html` is used **nowhere else in
the backend**, and `EpubExtractor._extract_description` returns
`epub.get_metadata("DC", "description")[0][0]` verbatim. EPUB `dc:description` routinely
carries markup, so an uploaded book with `<img src=x onerror=…>` in that field executes
script on the book page, with the session cookie in scope. Metadata edited through the UI
is stored unfiltered too.
This is the same class as the scripted-EPUB item below — untrusted file content reaching an
origin that holds a session — by a different route, and it does not need `allow-scripts` to
work.
Fix: sanitize at ingest, next to where Calibre already does. Reuse `strip_html` in
`_extract_description` if descriptions should be plain text, or run an allowlist sanitizer if
the formatting is worth keeping. Either way the stored rows need backfilling through the same
helper, since the validators only fire on write. Dropping `{@html}` to `{text}` in the
component fixes the display side but leaves the payload in the database.
These are the only two findings `pnpm exec eslint .` still reports; CI's eslint step stops
being `continue-on-error` once they are gone.
### Scripted EPUBs run against the app origin ### Scripted EPUBs run against the app origin
**This is a regression from the foliate-js migration, not a pre-existing gap.** **This is a regression from the foliate-js migration, not a pre-existing gap.**
+1
View File
@@ -36,6 +36,7 @@ dev = [
"pytest>=8.4.2", "pytest>=8.4.2",
"pytest-asyncio>=1.2.0", "pytest-asyncio>=1.2.0",
"pytest-databases[postgres]>=0.15.0", "pytest-databases[postgres]>=0.15.0",
"ruff==0.15.14",
] ]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
+2 -1
View File
@@ -4,7 +4,8 @@ pkgs.mkShell {
buildInputs = with pkgs; [ buildInputs = with pkgs; [
# Python development environment for Chitai # Python development environment for Chitai
python313Packages.greenlet python313Packages.greenlet
python313Packages.ruff # ruff is a dev dependency in pyproject.toml, not a shell package: CI has no nix, so a
# nix-only formatter is one CI cannot run, and two copies could disagree on formatting.
uv uv
# postgres database # postgres database
+2 -1
View File
@@ -2,6 +2,8 @@ from pathlib import Path
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from chitai import services
from advanced_alchemy.base import UUIDAuditBase from advanced_alchemy.base import UUIDAuditBase
from litestar.testing import AsyncTestClient from litestar.testing import AsyncTestClient
from sqlalchemy import text from sqlalchemy import text
@@ -155,7 +157,6 @@ async def other_authenticated_client(
# Service fixtures # Service fixtures
from chitai import services
@pytest.fixture @pytest.fixture
+43 -34
View File
@@ -1,6 +1,7 @@
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from pathlib import Path from pathlib import Path
from litestar.status_codes import HTTP_400_BAD_REQUEST
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -218,7 +219,9 @@ async def test_list_books_by_id(populated_authenticated_client: AsyncClient) ->
compare a bigint primary key against them. Nothing called it until a screen needed compare a bigint primary key against them. Nothing called it until a screen needed
to fetch a handful of books by id. to fetch a handful of books by id.
""" """
response = await populated_authenticated_client.get("/books?ids=1&ids=2&pageSize=10") response = await populated_authenticated_client.get(
"/books?ids=1&ids=2&pageSize=10"
)
assert response.status_code == 200 assert response.status_code == 200
assert sorted(book["id"] for book in response.json()["items"]) == [1, 2] assert sorted(book["id"] for book in response.json()["items"]) == [1, 2]
@@ -228,7 +231,7 @@ async def test_get_book_by_id(populated_authenticated_client: AsyncClient) -> No
"""Test retrieving a specific book by ID.""" """Test retrieving a specific book by ID."""
# Retrieve the book # Retrieve the book
response = await populated_authenticated_client.get(f"/books/1") response = await populated_authenticated_client.get("/books/1")
assert response.status_code == 200 assert response.status_code == 200
book_data = response.json() book_data = response.json()
@@ -300,13 +303,13 @@ async def test_delete_book_metadata_only(
# Delete book without deleting files # Delete book without deleting files
response = await populated_authenticated_client.delete( response = await populated_authenticated_client.delete(
f"/books?book_ids=3&delete_files=false&library_id=1" "/books?book_ids=3&delete_files=false&library_id=1"
) )
assert response.status_code == 204 assert response.status_code == 204
# Verify book is deleted # Verify book is deleted
get_response = await populated_authenticated_client.get(f"/books/3") get_response = await populated_authenticated_client.get("/books/3")
assert get_response.status_code == 404 assert get_response.status_code == 404
@@ -317,7 +320,7 @@ async def test_delete_book_with_files(
# Delete book and files # Delete book and files
response = await populated_authenticated_client.delete( response = await populated_authenticated_client.delete(
f"/books?book_ids=3&delete_files=true&library_id=1" "/books?book_ids=3&delete_files=true&library_id=1"
) )
assert response.status_code == 204 assert response.status_code == 204
@@ -330,7 +333,7 @@ async def test_delete_specific_book_files(
# Delete specific file # Delete specific file
response = await populated_authenticated_client.delete( response = await populated_authenticated_client.delete(
f"/books/1/files?file_ids=1", "/books/1/files?file_ids=1",
) )
assert response.status_code == 204 assert response.status_code == 204
@@ -347,7 +350,7 @@ async def test_update_reading_progress(
} }
response = await populated_authenticated_client.post( response = await populated_authenticated_client.post(
f"/books/progress/1", "/books/progress/1",
json=progress_data, json=progress_data,
) )
@@ -423,7 +426,9 @@ async def test_create_books_groups_formats_within_one_folder(
) -> None: ) -> None:
"""Picking a book's own folder yields one book with both formats, not two books.""" """Picking a book's own folder yields one book with both formats, not two books."""
epub = Path("tests/data_files/Metamorphosis - Franz Kafka.epub").read_bytes() epub = Path("tests/data_files/Metamorphosis - Franz Kafka.epub").read_bytes()
pdf = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf").read_bytes() pdf = Path(
"tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf"
).read_bytes()
files = [ files = [
("files", ("Metamorphosis/Metamorphosis.epub", epub, "application/epub+zip")), ("files", ("Metamorphosis/Metamorphosis.epub", epub, "application/epub+zip")),
@@ -534,7 +539,9 @@ class TestDuplicateHandling:
"files", "files",
( (
"war.epub", "war.epub",
Path("tests/data_files/The Art of War - Sun Tzu.epub").read_bytes(), Path(
"tests/data_files/The Art of War - Sun Tzu.epub"
).read_bytes(),
"application/epub+zip", "application/epub+zip",
), ),
) )
@@ -550,7 +557,9 @@ class TestDuplicateHandling:
"files", "files",
( (
"war.epub", "war.epub",
Path("tests/data_files/The Art of War - Sun Tzu.epub").read_bytes(), Path(
"tests/data_files/The Art of War - Sun Tzu.epub"
).read_bytes(),
"application/epub+zip", "application/epub+zip",
), ),
) )
@@ -736,7 +745,9 @@ class TestDuplicateBooks:
assert len(merged["files"]) == 2 assert len(merged["files"]) == 2
# The folded record is gone, and the group it formed with it. # The folded record is gone, and the group it formed with it.
assert (await authenticated_client.get(f"/books/{fold['id']}")).status_code == 404 assert (
await authenticated_client.get(f"/books/{fold['id']}")
).status_code == 404
groups = await authenticated_client.get("/books/duplicate-books?library_id=1") groups = await authenticated_client.get("/books/duplicate-books?library_id=1")
assert groups.json() == [] assert groups.json() == []
@@ -786,16 +797,6 @@ class TestDuplicateBooks:
# async def test_edit_book_metadata(authenticated_client: AsyncClient) -> None: # async def test_edit_book_metadata(authenticated_client: AsyncClient) -> None:
# raise NotImplementedError() # raise NotImplementedError()
import pytest
import aiofiles
from httpx import AsyncClient
from pathlib import Path
from litestar.status_codes import HTTP_400_BAD_REQUEST
import pytest
from httpx import AsyncClient
from datetime import date
class TestMetadataUpdates: class TestMetadataUpdates:
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -849,8 +850,10 @@ class TestMetadataUpdates:
( (
"authors", # Update with new authors "authors", # Update with new authors
["New Author 1", "New Author 2"], ["New Author 1", "New Author 2"],
lambda data: {a["name"] for a in data["authors"]} lambda data: (
== {"New Author 1", "New Author 2"}, {a["name"] for a in data["authors"]}
== {"New Author 1", "New Author 2"}
),
), ),
( (
"authors", # Clear authors "authors", # Clear authors
@@ -860,8 +863,9 @@ class TestMetadataUpdates:
( (
"tags", # Update with new tags "tags", # Update with new tags
["Tag 1", "Tag 2", "Tag 3"], ["Tag 1", "Tag 2", "Tag 3"],
lambda data: {t["name"] for t in data["tags"]} lambda data: (
== {"Tag 1", "Tag 2", "Tag 3"}, {t["name"] for t in data["tags"]} == {"Tag 1", "Tag 2", "Tag 3"}
),
), ),
( (
"tags", # Clear tags "tags", # Clear tags
@@ -881,8 +885,10 @@ class TestMetadataUpdates:
( (
"identifiers", # Update with new identifiers "identifiers", # Update with new identifiers
{"isbn-13": "978-1234567890", "doi": "10.example/id"}, {"isbn-13": "978-1234567890", "doi": "10.example/id"},
lambda data: data["identifiers"] lambda data: (
== {"isbn-13": "978-1234567890", "doi": "10.example/id"}, data["identifiers"]
== {"isbn-13": "978-1234567890", "doi": "10.example/id"}
),
), ),
( (
"identifiers", # Clear identifiers "identifiers", # Clear identifiers
@@ -1053,7 +1059,7 @@ class TestMetadataUpdates:
result = response.json() result = response.json()
assert result[updated_field] == None assert result[updated_field] is None
@pytest.mark.parametrize( @pytest.mark.parametrize(
("updated_field"), ("updated_field"),
@@ -1215,7 +1221,9 @@ class TestFileManagement:
pytest.skip("Book has no files") pytest.skip("Book has no files")
file_id = book_data["files"][0]["id"] file_id = book_data["files"][0]["id"]
filename = book_data["files"][0].get("path") # TODO: this test asserts only the 204 and never checks the disk, so the flag it is
# named for is untested. See TODO.md; drop the noqa when the assertion lands.
filename = book_data["files"][0].get("path") # noqa: F841
# Remove file without deleting from filesystem # Remove file without deleting from filesystem
response = await populated_authenticated_client.delete( response = await populated_authenticated_client.delete(
@@ -1240,7 +1248,8 @@ class TestFileManagement:
book_data = add_response.json() book_data = add_response.json()
file_id = book_data["files"][-1]["id"] file_id = book_data["files"][-1]["id"]
file_path = book_data["files"][-1].get("path") # TODO: as above -- the file is never checked for removal from disk.
file_path = book_data["files"][-1].get("path") # noqa: F841
# Remove file with deletion from filesystem # Remove file with deletion from filesystem
response = await populated_authenticated_client.delete( response = await populated_authenticated_client.delete(
@@ -1295,7 +1304,9 @@ class TestUnnameableFormats:
self, authenticated_client: AsyncClient self, authenticated_client: AsyncClient
) -> None: ) -> None:
response = await authenticated_client.post( response = await authenticated_client.post(
"/books?library_id=1", files=self.upload("Dune.mobi"), data={"library_id": 1} "/books?library_id=1",
files=self.upload("Dune.mobi"),
data={"library_id": 1},
) )
assert response.status_code == 201 assert response.status_code == 201
@@ -1323,9 +1334,7 @@ class TestUnnameableFormats:
assert detail.status_code == 200 assert detail.status_code == 200
assert detail.json()["files"][0]["content_type"] is None assert detail.json()["files"][0]["content_type"] is None
async def test_the_file_downloads( async def test_the_file_downloads(self, authenticated_client: AsyncClient) -> None:
self, authenticated_client: AsyncClient
) -> None:
"""Litestar supplies its own media type when the row carries none.""" """Litestar supplies its own media type when the row carries none."""
created = await authenticated_client.post( created = await authenticated_client.post(
"/books?library_id=1", "/books?library_id=1",
@@ -1,4 +1,3 @@
import pytest
from httpx import AsyncClient from httpx import AsyncClient
@@ -200,7 +199,6 @@ async def test_remove_books_from_shelf(
"/books", params={"shelves": shelf_id} "/books", params={"shelves": shelf_id}
) )
assert books_response.status_code == 200 assert books_response.status_code == 200
assert books_response.json()["total"] == 2 assert books_response.json()["total"] == 2
@@ -42,7 +42,9 @@ def fx_source(tmp_path: Path) -> Path:
cover=True, cover=True,
formats={"EPUB": EPUB}, formats={"EPUB": EPUB},
) )
fixture.add_book(2, "The Art of War", authors=["Sun Tzu"], formats={"EPUB": OTHER_EPUB}) fixture.add_book(
2, "The Art of War", authors=["Sun Tzu"], formats={"EPUB": OTHER_EPUB}
)
fixture.add_book(3, "Metadata Only", authors=["Nobody"]) fixture.add_book(3, "Metadata Only", authors=["Nobody"])
return fixture.commit() return fixture.commit()
@@ -95,7 +97,8 @@ async def test_an_uploaded_library_imports(
authenticated_client: AsyncClient, source: Path, tmp_path: Path authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None: ) -> None:
status, job = await upload( status, job = await upload(
authenticated_client, zip_of(source, tmp_path / "out", prefix="Calibre Library/") authenticated_client,
zip_of(source, tmp_path / "out", prefix="Calibre Library/"),
) )
assert status == 202 assert status == 202
@@ -278,7 +281,9 @@ async def test_a_second_copy_is_counted_as_a_possible_duplicate(
padded.write_bytes(EPUB.read_bytes() + b"\0" * 64) padded.write_bytes(EPUB.read_bytes() + b"\0" * 64)
fixture = CalibreFixture(tmp_path / "calibre") fixture = CalibreFixture(tmp_path / "calibre")
fixture.add_book(1, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": EPUB}) fixture.add_book(
1, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": EPUB}
)
fixture.add_book( fixture.add_book(
2, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": padded} 2, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": padded}
) )
+12 -3
View File
@@ -8,7 +8,9 @@ from pathlib import Path
# Known KOReader hashes for test files # Known KOReader hashes for test files
TEST_FILES = { TEST_FILES = {
"Moby Dick; Or, The Whale - Herman Melville.epub": { "Moby Dick; Or, The Whale - Herman Melville.epub": {
"path": Path("tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub"), "path": Path(
"tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub"
),
"hash": "ceeef909ec65653ba77e1380dff998fb", "hash": "ceeef909ec65653ba77e1380dff998fb",
"content_type": "application/epub+zip", "content_type": "application/epub+zip",
}, },
@@ -59,7 +61,9 @@ async def test_add_file_to_book_generates_correct_hash(
first_book = TEST_FILES["Moby Dick; Or, The Whale - Herman Melville.epub"] first_book = TEST_FILES["Moby Dick; Or, The Whale - Herman Melville.epub"]
first_content = first_book["path"].read_bytes() first_content = first_book["path"].read_bytes()
files = [("files", (first_book["path"].name, first_content, first_book["content_type"]))] files = [
("files", (first_book["path"].name, first_content, first_book["content_type"]))
]
data = {"library_id": "1"} data = {"library_id": "1"}
create_response = await authenticated_client.post( create_response = await authenticated_client.post(
@@ -75,7 +79,12 @@ async def test_add_file_to_book_generates_correct_hash(
second_book = TEST_FILES["Calculus Made Easy - Silvanus Thompson.pdf"] second_book = TEST_FILES["Calculus Made Easy - Silvanus Thompson.pdf"]
second_content = second_book["path"].read_bytes() second_content = second_book["path"].read_bytes()
add_files = [("data", (second_book["path"].name, second_content, second_book["content_type"]))] add_files = [
(
"data",
(second_book["path"].name, second_content, second_book["content_type"]),
)
]
add_response = await authenticated_client.post( add_response = await authenticated_client.post(
f"/books/{book_id}/files", f"/books/{book_id}/files",
+1 -1
View File
@@ -40,5 +40,5 @@ async def test_create_library(
assert result["name"] == "Test Library" assert result["name"] == "Test Library"
assert result["root_path"] == f"{tmp_path}/books" assert result["root_path"] == f"{tmp_path}/books"
assert result["path_template"] == "{author}/{title}" assert result["path_template"] == "{author}/{title}"
assert result["read_only"] == False assert result["read_only"] is False
assert result["description"] is None assert result["description"] is None
+22 -7
View File
@@ -2,7 +2,10 @@
from pathlib import Path from pathlib import Path
from chitai.services.filesystem_library import BookPathGenerator, sanitize_path_component from chitai.services.filesystem_library import (
BookPathGenerator,
sanitize_path_component,
)
ROOT = Path("/library") ROOT = Path("/library")
@@ -23,12 +26,15 @@ def test_a_book_with_no_authors() -> None:
def test_a_series_adds_a_level_and_pads_the_position() -> None: def test_a_series_adds_a_level_and_pads_the_position() -> None:
assert path_for( assert (
path_for(
title="Persepolis Rising", title="Persepolis Rising",
authors=["James S. A. Corey"], authors=["James S. A. Corey"],
series="The Expanse", series="The Expanse",
series_position="7", series_position="7",
) == ROOT / "James S. A. Corey" / "The Expanse" / "07 - Persepolis Rising" )
== ROOT / "James S. A. Corey" / "The Expanse" / "07 - Persepolis Rising"
)
def test_a_slash_in_a_title_does_not_add_a_directory() -> None: def test_a_slash_in_a_title_does_not_add_a_directory() -> None:
@@ -43,16 +49,25 @@ def test_a_slash_in_a_title_does_not_add_a_directory() -> None:
generated = path_for(title="Back in Black: AC/DC", authors=["Murray Engleheart"]) generated = path_for(title="Back in Black: AC/DC", authors=["Murray Engleheart"])
assert generated == ROOT / "Murray Engleheart" / "Back in Black: AC_DC" assert generated == ROOT / "Murray Engleheart" / "Back in Black: AC_DC"
assert generated.relative_to(ROOT).parts == ("Murray Engleheart", "Back in Black: AC_DC") assert generated.relative_to(ROOT).parts == (
"Murray Engleheart",
"Back in Black: AC_DC",
)
def test_a_slash_in_an_author_or_series_is_handled_too() -> None: def test_a_slash_in_an_author_or_series_is_handled_too() -> None:
assert path_for(title="Split", authors=["A/B Collective"]) == ( assert path_for(title="Split", authors=["A/B Collective"]) == (
ROOT / "A_B Collective" / "Split" ROOT / "A_B Collective" / "Split"
) )
assert path_for( assert (
title="Volume One", authors=["Someone"], series="Either/Or", series_position="1" path_for(
) == ROOT / "Someone" / "Either_Or" / "01 - Volume One" title="Volume One",
authors=["Someone"],
series="Either/Or",
series_position="1",
)
== ROOT / "Someone" / "Either_Or" / "01 - Volume One"
)
def test_control_characters_are_removed() -> None: def test_control_characters_are_removed() -> None:
+12 -4
View File
@@ -54,7 +54,8 @@ class TestNormalizeTitle:
assert normalize_title("The") == "the" assert normalize_title("The") == "the"
@pytest.mark.parametrize( @pytest.mark.parametrize(
"title", ["Catch 22", "Fahrenheit 451", "Blade Runner 2049", "1984", "Apollo 13"] "title",
["Catch 22", "Fahrenheit 451", "Blade Runner 2049", "1984", "Apollo 13"],
) )
def test_a_number_is_not_an_edition(self, title: str) -> None: def test_a_number_is_not_an_edition(self, title: str) -> None:
"""Edition stripping keys on the `e`; a bare number is part of the title.""" """Edition stripping keys on the `e`; a bare number is part of the title."""
@@ -163,8 +164,13 @@ class TestNormalizeIdentifier:
def test_uuids_are_refused(self) -> None: def test_uuids_are_refused(self) -> None:
"""Generated per build, so they only re-find what the hash check catches.""" """Generated per build, so they only re-find what the hash check catches."""
assert normalize_identifier("uuid", "3f2b1c4e-1111-2222-3333-444455556666") is None assert (
assert normalize_identifier("urn:uuid", "3f2b1c4e-1111-2222-3333-444455556666") is None normalize_identifier("uuid", "3f2b1c4e-1111-2222-3333-444455556666") is None
)
assert (
normalize_identifier("urn:uuid", "3f2b1c4e-1111-2222-3333-444455556666")
is None
)
def test_other_schemes_keep_their_own_key(self) -> None: def test_other_schemes_keep_their_own_key(self) -> None:
assert normalize_identifier("asin", "B000FC0PDA") == "asin:b000fc0pda" assert normalize_identifier("asin", "B000FC0PDA") == "asin:b000fc0pda"
@@ -187,7 +193,9 @@ class TestIsbnConversion:
assert isbn10_to_isbn13("043942089X") == "9780439420891" assert isbn10_to_isbn13("043942089X") == "9780439420891"
@pytest.mark.parametrize("isbn", ["0486282113", "9780486282114", "nonsense"]) @pytest.mark.parametrize("isbn", ["0486282113", "9780486282114", "nonsense"])
def test_anything_that_is_not_an_isbn_10_converts_to_nothing(self, isbn: str) -> None: def test_anything_that_is_not_an_isbn_10_converts_to_nothing(
self, isbn: str
) -> None:
assert isbn10_to_isbn13(isbn) is None assert isbn10_to_isbn13(isbn) is None
+13 -5
View File
@@ -22,7 +22,6 @@ class TestEpubExtractor:
assert metadata["published_date"] == date(year=2001, month=7, day=1) assert metadata["published_date"] == date(year=2001, month=7, day=1)
EPUB = Path("tests/data_files/Metamorphosis - Franz Kafka.epub") EPUB = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
PDF = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf") PDF = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf")
@@ -31,7 +30,9 @@ PDF = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf")
class TestIdentifierMerging: class TestIdentifierMerging:
"""A book's formats each contribute identifiers; none of them replaces the rest.""" """A book's formats each contribute identifiers; none of them replaces the rest."""
async def test_every_format_contributes(self, monkeypatch: pytest.MonkeyPatch) -> None: async def test_every_format_contributes(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
""" """
Identifiers are a collection, not a single value. Identifiers are a collection, not a single value.
@@ -47,7 +48,9 @@ class TestIdentifierMerging:
} }
async def pdf(_file): async def pdf(_file):
return {"identifiers": {"isbn-13": "9781718500402", "isbn-10": "1593270356"}} return {
"identifiers": {"isbn-13": "9781718500402", "isbn-10": "1593270356"}
}
monkeypatch.setattr(EpubExtractor, "extract_metadata", epub) monkeypatch.setattr(EpubExtractor, "extract_metadata", epub)
monkeypatch.setattr(PdfExtractor, "extract_metadata", pdf) monkeypatch.setattr(PdfExtractor, "extract_metadata", pdf)
@@ -133,7 +136,9 @@ class TestSplitEdition:
), ),
], ],
) )
def test_editions_are_split_out(self, title: str, stripped: str, edition: int) -> None: def test_editions_are_split_out(
self, title: str, stripped: str, edition: int
) -> None:
assert split_edition(title) == (stripped, edition) assert split_edition(title) == (stripped, edition)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -171,7 +176,10 @@ class TestEditionFromFiles:
"""The PDF fixture calls itself a 2nd edition in its own metadata title.""" """The PDF fixture calls itself a 2nd edition in its own metadata title."""
metadata = await Extractor.extract_metadata([PDF]) metadata = await Extractor.extract_metadata([PDF])
assert metadata["title"] == "The Project Gutenberg eBook #33283: Calculus Made Easy" assert (
metadata["title"]
== "The Project Gutenberg eBook #33283: Calculus Made Easy"
)
assert metadata["edition"] == 2 assert metadata["edition"] == 2
@@ -25,7 +25,9 @@ PDF = DATA_FILES / "Calculus Made Easy - Silvanus Thompson.pdf"
def upload(path: Path, name: str | None = None) -> UploadFile: def upload(path: Path, name: str | None = None) -> UploadFile:
"""An uploaded file carrying the bytes of one of the test fixtures.""" """An uploaded file carrying the bytes of one of the test fixtures."""
return UploadFile( return UploadFile(
content_type="application/pdf" if path.suffix == ".pdf" else "application/epub+zip", content_type="application/pdf"
if path.suffix == ".pdf"
else "application/epub+zip",
filename=name or path.name, filename=name or path.name,
file_data=path.read_bytes(), file_data=path.read_bytes(),
) )
@@ -538,9 +540,7 @@ class TestBookPathCollisions:
assert original.path != forced.path assert original.path != forced.path
paths = { paths = {Path(book.path) / book.files[0].path for book in (original, forced)}
Path(book.path) / book.files[0].path for book in (original, forced)
}
assert len(paths) == 2 assert len(paths) == 2
assert all(path.is_file() for path in paths) assert all(path.is_file() for path in paths)
@@ -581,7 +581,10 @@ class TestBookPathCollisions:
# Renamed onto the first book's author and title. # Renamed onto the first book's author and title.
await books_service.update_book( await books_service.update_book(
second.books[0].id, second.books[0].id,
{"title": original.title, "authors": [author.name for author in original.authors]}, {
"title": original.title,
"authors": [author.name for author in original.authors],
},
test_library, test_library,
) )
@@ -743,7 +746,8 @@ class TestDuplicateBooks:
) )
matches = await books_service.find_duplicate_books( matches = await books_service.find_duplicate_books(
{"title": "Building Microservices", "authors": ["Newman, Sam;"]}, test_library {"title": "Building Microservices", "authors": ["Newman, Sam;"]},
test_library,
) )
assert [match.book_id for match in matches] == [stored.id] assert [match.book_id for match in matches] == [stored.id]
@@ -834,16 +838,22 @@ class TestDuplicateBooks:
"series": "Foundation", "series": "Foundation",
} }
assert await books_service.find_duplicate_books( assert (
await books_service.find_duplicate_books(
incoming | {"series_position": "2"}, test_library incoming | {"series_position": "2"}, test_library
) == [] )
== []
)
# The same volume, written a little differently, still matches. # The same volume, written a little differently, still matches.
assert len( assert (
len(
await books_service.find_duplicate_books( await books_service.find_duplicate_books(
incoming | {"series_position": "1.0"}, test_library incoming | {"series_position": "1.0"}, test_library
) )
) == 1 )
== 1
)
async def test_a_book_is_not_its_own_duplicate( async def test_a_book_is_not_its_own_duplicate(
self, books_service: BookService, test_library: m.Library self, books_service: BookService, test_library: m.Library
@@ -909,7 +919,10 @@ class TestAuthorNames:
existing row and then collides with it on the unique index. existing row and then collides with it on the unique index.
""" """
first = await store_book( first = await store_book(
books_service, test_library, title="Building Microservices", authors=["Sam Newman"] books_service,
test_library,
title="Building Microservices",
authors=["Sam Newman"],
) )
second = await store_book( second = await store_book(
books_service, books_service,
@@ -953,7 +966,9 @@ class TestAuthorNames:
"Franz Kafka.epub" is not a person. "Franz Kafka.epub" is not a person.
""" """
result = await books_service.create_many_from_files( result = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB, "Some Unknown Book - Ada Lovelace.epub")]), BooksCreateFromFiles(
files=[upload(EPUB, "Some Unknown Book - Ada Lovelace.epub")]
),
test_library, test_library,
) )
@@ -1014,7 +1029,10 @@ class TestMergeBooks:
) -> None: ) -> None:
"""The survivor keeps its own fields unless the caller says otherwise.""" """The survivor keeps its own fields unless the caller says otherwise."""
keep = await store_book( keep = await store_book(
books_service, test_library, title="Building Microservices", authors=["Sam Newman"] books_service,
test_library,
title="Building Microservices",
authors=["Sam Newman"],
) )
fold = await store_book( fold = await store_book(
books_service, books_service,
@@ -1030,7 +1048,11 @@ class TestMergeBooks:
assert merged.publisher is None assert merged.publisher is None
other = await store_book( other = await store_book(
books_service, test_library, title="Monolith", authors=["Sam Newman"], edition=3 books_service,
test_library,
title="Monolith",
authors=["Sam Newman"],
edition=3,
) )
merged = await books_service.merge_books( merged = await books_service.merge_books(
keep.id, [other.id], test_library, metadata={"edition": 3} keep.id, [other.id], test_library, metadata={"edition": 3}
@@ -1096,10 +1118,14 @@ class TestMergeBooks:
await books_service.merge_books(keep.id, [fold.id], test_library) await books_service.merge_books(keep.id, [fold.id], test_library)
rows = ( rows = (
(
await books_service.repository.session.execute( await books_service.repository.session.execute(
select(m.BookProgress).where(m.BookProgress.book_id == keep.id) select(m.BookProgress).where(m.BookProgress.book_id == keep.id)
) )
).scalars().all() )
.scalars()
.all()
)
assert [row.percentage for row in rows] == [0.6] assert [row.percentage for row in rows] == [0.6]
@@ -1112,13 +1138,23 @@ class TestMergeBooks:
) -> None: ) -> None:
"""Both books on one shelf must not leave the survivor linked to it twice.""" """Both books on one shelf must not leave the survivor linked to it twice."""
keep = await store_book( keep = await store_book(
books_service, test_library, title="A", authors=["X"], tags=["Shared", "Only Keep"] books_service,
test_library,
title="A",
authors=["X"],
tags=["Shared", "Only Keep"],
) )
fold = await store_book( fold = await store_book(
books_service, test_library, title="B", authors=["X"], tags=["Shared", "Only Fold"] books_service,
test_library,
title="B",
authors=["X"],
tags=["Shared", "Only Fold"],
) )
shelf = m.BookList(title="Later", user_id=test_user.id, library_id=test_library.id) shelf = m.BookList(
title="Later", user_id=test_user.id, library_id=test_library.id
)
session.add(shelf) session.add(shelf)
await session.commit() await session.commit()
session.add_all( session.add_all(
@@ -1131,13 +1167,21 @@ class TestMergeBooks:
merged = await books_service.merge_books(keep.id, [fold.id], test_library) merged = await books_service.merge_books(keep.id, [fold.id], test_library)
assert sorted(tag.name for tag in merged.tags) == ["Only Fold", "Only Keep", "Shared"] assert sorted(tag.name for tag in merged.tags) == [
"Only Fold",
"Only Keep",
"Shared",
]
links = ( links = (
(
await books_service.repository.session.execute( await books_service.repository.session.execute(
select(m.BookListLink).where(m.BookListLink.book_id == keep.id) select(m.BookListLink).where(m.BookListLink.book_id == keep.id)
) )
).scalars().all() )
.scalars()
.all()
)
assert len(links) == 1 assert len(links) == 1
async def test_an_identifier_moves_only_under_a_name_the_survivor_lacks( async def test_an_identifier_moves_only_under_a_name_the_survivor_lacks(
@@ -2,19 +2,12 @@
import pytest import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from chitai.services import ShelfService from chitai.services import ShelfService
from chitai.database import models as m from chitai.database import models as m
import pytest
from sqlalchemy import select
from chitai.services.bookshelf import ShelfService
from chitai.services import BookService
from chitai.database.models.book_list import BookList, BookListLink from chitai.database.models.book_list import BookList, BookListLink
from chitai.database import models as m
@pytest.fixture @pytest.fixture
@@ -201,7 +201,9 @@ async def test_importing_twice_creates_nothing(
] ]
held_by = [ held_by = [
skipped.book_id for skipped in result.skipped if skipped.reason == "already stored" skipped.book_id
for skipped in result.skipped
if skipped.reason == "already stored"
] ]
assert all(book_id is not None for book_id in held_by) assert all(book_id is not None for book_id in held_by)
@@ -223,7 +225,9 @@ async def test_a_file_the_catalogue_lists_but_disk_does_not(
await source.close() await source.close()
assert len(result.created) == 1 assert len(result.created) == 1
assert [(s.calibre_id, s.reason) for s in result.skipped] == [(2, "no files on disk")] assert [(s.calibre_id, s.reason) for s in result.skipped] == [
(2, "no files on disk")
]
async def test_one_broken_book_does_not_stop_the_import( async def test_one_broken_book_does_not_stop_the_import(
@@ -312,7 +316,11 @@ async def test_shared_authors_and_tags_are_one_row_each(
1, "One", authors=["Franz Kafka"], tags=["Fiction"], formats={"EPUB": EPUB} 1, "One", authors=["Franz Kafka"], tags=["Fiction"], formats={"EPUB": EPUB}
) )
fixture.add_book( fixture.add_book(
2, "Two", authors=["Franz Kafka"], tags=["Fiction"], formats={"EPUB": OTHER_EPUB} 2,
"Two",
authors=["Franz Kafka"],
tags=["Fiction"],
formats={"EPUB": OTHER_EPUB},
) )
root = fixture.commit() root = fixture.commit()
@@ -34,8 +34,8 @@ class TestLibraryServiceCRUD:
assert library.name == "Test Library" assert library.name == "Test Library"
assert library.root_path == library_path assert library.root_path == library_path
assert library.path_template == "{author}/{title}" assert library.path_template == "{author}/{title}"
assert library.description == None assert library.description is None
assert library.read_only == False assert library.read_only is False
# Check if directory was created # Check if directory was created
assert Path(library.root_path).is_dir() assert Path(library.root_path).is_dir()
@@ -56,8 +56,8 @@ class TestLibraryServiceCRUD:
read_only=False, read_only=False,
) )
with pytest.raises(PermissionError) as exc_info: with pytest.raises(PermissionError):
library = await library_service.create(library_data) await library_service.create(library_data)
# Check if directory was created # Check if directory was created
assert not Path(library_path).exists() assert not Path(library_path).exists()
@@ -86,8 +86,8 @@ class TestLibraryServiceCRUD:
assert library.name == "Test Library" assert library.name == "Test Library"
assert library.root_path == library_path assert library.root_path == library_path
assert library.path_template == "{author}/{title}" assert library.path_template == "{author}/{title}"
assert library.description == None assert library.description is None
assert library.read_only == True assert library.read_only is True
async def test_create_library_read_only_nonexistent_path( async def test_create_library_read_only_nonexistent_path(
self, library_service: LibraryService, tmp_path: Path self, library_service: LibraryService, tmp_path: Path
@@ -138,7 +138,7 @@ class TestLibraryServiceCRUD:
assert library.root_path == "./books" assert library.root_path == "./books"
assert library.path_template == "{author}/{title}" assert library.path_template == "{author}/{title}"
assert library.description is None assert library.description is None
assert library.read_only == False assert library.read_only is False
# async def test_delete_library_keep_files( # async def test_delete_library_keep_files(
# self, session: AsyncSession, library_service: LibraryService # self, session: AsyncSession, library_service: LibraryService
@@ -21,7 +21,7 @@ class TestUserServiceAuthentication:
# Create a user with a known password # Create a user with a known password
password = "password123" password = "password123"
user = m.User(email=f"test@example.com", password=password) user = m.User(email="test@example.com", password=password)
session.add(user) session.add(user)
await session.commit() await session.commit()
@@ -52,7 +52,7 @@ class TestUserServiceAuthentication:
# Create user # Create user
password = "password123" password = "password123"
user = m.User(email=f"test@example.com", password=password) user = m.User(email="test@example.com", password=password)
session.add(user) session.add(user)
await session.commit() await session.commit()
@@ -85,7 +85,7 @@ class TestUserServiceCRUD:
) -> None: ) -> None:
"""Test getting user by email.""" """Test getting user by email."""
user = m.User(email=f"test@example.com", password="password123") user = m.User(email="test@example.com", password="password123")
session.add(user) session.add(user)
await session.commit() await session.commit()
@@ -102,12 +102,12 @@ class TestUserServiceCRUD:
"""Test creating a new user with a duplicate email.""" """Test creating a new user with a duplicate email."""
# Create first user # Create first user
user = m.User(email=f"test@example.com", password="password123") user = m.User(email="test@example.com", password="password123")
session.add(user) session.add(user)
await session.commit() await session.commit()
# Create second user # Create second user
user = m.User(email=f"test@example.com", password="password12345") user = m.User(email="test@example.com", password="password12345")
with pytest.raises(IntegrityError) as exc_info: with pytest.raises(IntegrityError) as exc_info:
session.add(user) session.add(user)
+27
View File
@@ -261,6 +261,7 @@ dev = [
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "pytest-databases", extra = ["postgres"] }, { name = "pytest-databases", extra = ["postgres"] },
{ name = "ruff" },
] ]
[package.metadata] [package.metadata]
@@ -286,6 +287,7 @@ dev = [
{ name = "pytest", specifier = ">=8.4.2" }, { name = "pytest", specifier = ">=8.4.2" },
{ name = "pytest-asyncio", specifier = ">=1.2.0" }, { name = "pytest-asyncio", specifier = ">=1.2.0" },
{ name = "pytest-databases", extras = ["postgres"], specifier = ">=0.15.0" }, { name = "pytest-databases", extras = ["postgres"], specifier = ">=0.15.0" },
{ name = "ruff", specifier = "==0.15.14" },
] ]
[[package]] [[package]]
@@ -1277,6 +1279,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/e5/d708d262b600a352abe01c2ae360d8ff75b0af819b78e9af293191d928e6/rich_click-1.9.7-py3-none-any.whl", hash = "sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b", size = 71491, upload-time = "2026-01-31T04:29:26.777Z" }, { url = "https://files.pythonhosted.org/packages/ca/e5/d708d262b600a352abe01c2ae360d8ff75b0af819b78e9af293191d928e6/rich_click-1.9.7-py3-none-any.whl", hash = "sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b", size = 71491, upload-time = "2026-01-31T04:29:26.777Z" },
] ]
[[package]]
name = "ruff"
version = "0.15.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" },
{ url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" },
{ url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" },
{ url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" },
{ url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" },
{ url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" },
{ url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" },
{ url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" },
{ url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" },
{ url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" },
{ url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" },
{ url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" },
{ url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" },
{ url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" },
{ url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" },
]
[[package]] [[package]]
name = "six" name = "six"
version = "1.17.0" version = "1.17.0"
+31 -6
View File
@@ -99,12 +99,37 @@ the workflow.
1. **A registered `act_runner`.** Gitea Actions is enabled instance-side but does nothing without a 1. **A registered `act_runner`.** Gitea Actions is enabled instance-side but does nothing without a
runner. Register one against the repo or the instance with the label `ubuntu-latest`. runner. Register one against the repo or the instance with the label `ubuntu-latest`.
2. **The runner needs a Docker daemon.** This is the single most common failure for image-building 2. **The runner needs a Docker daemon, at an address the job can reach.** `act_runner` in docker
workflows on Gitea. `act_runner` in docker mode runs each job inside a container that has no mode runs each job inside a container with no daemon of its own. Either run a `docker:dind`
daemon of its own. Either run a `docker:dind` sidecar next to the runner and set sidecar next to the runner and set `DOCKER_HOST=tcp://docker:2376` (with TLS certs shared over a
`DOCKER_HOST=tcp://docker:2376` (with TLS certs shared over a volume), or run the runner in host volume), or run the runner in host mode. The dind sidecar is the safer of the two — mounting the
mode with the socket mounted. The dind sidecar is the safer of the two — mounting the host socket host socket into job containers gives any workflow root on the runner host.
into job containers gives any workflow root on the runner host.
**Reachability is a separate question from availability, and it bit us.** A containerised job
with a working daemon still fails `pytest tests/` with
`Service 'pytest_databases_postgres' failed to come online`: the database container starts
fine, but its published port lands on the daemon's network namespace while the test process
looks for it on the job container's loopback.
Two variables control two different things, and both must be set:
| Variable | Decides | Read by |
| --- | --- | --- |
| `DOCKER_HOST` | which daemon the container is created on | `_service.py` `get_docker_host()` |
| `POSTGRES_HOST` | the address the test then connects to | `docker/postgres.py` `postgres_host`, default `127.0.0.1` |
Setting only `DOCKER_HOST` is not enough — `DockerService.run()` takes `container_host` as a
plain argument defaulting to `127.0.0.1`, and the postgres fixture fills it from
`POSTGRES_HOST`. (There *is* a `DOCKER_HOST`-parsing helper in `pytest_databases`, but it is
`_get_docker_ip()` on the docker-compose class in `docker/__init__.py` and no part of this
path uses it. Do not be misled by it, as I was.)
Until the runner grows its own sidecar, `ci.yml`'s backend job carries a `docker:dind` service
of its own with `DOCKER_HOST: tcp://docker:2375`. That needs the runner to permit
`--privileged`. The same treatment is still owed to `release.yml` — its `quality` job runs the
same tests, and its `smoke` job talks to compose, where the published ports would move to the
dind host too, so `curl http://localhost:3000` becomes `curl http://docker:3000`. Configuring
the runner once (option 1) avoids all of that.
3. **Action resolution.** A bare `uses: docker/build-push-action@v6` does not mean github.com here. 3. **Action resolution.** A bare `uses: docker/build-push-action@v6` does not mean github.com here.
Gitea resolves it against `[actions] DEFAULT_ACTIONS_URL`, which defaults to `https://gitea.com`. Gitea resolves it against `[actions] DEFAULT_ACTIONS_URL`, which defaults to `https://gitea.com`.
That is fine as it stands — `actions/checkout@v4`, `docker/setup-buildx-action@v3`, That is fine as it stands — `actions/checkout@v4`, `docker/setup-buildx-action@v3`,
+10 -6
View File
@@ -157,18 +157,22 @@ but take a baseline first, because neither is clean (see below).
Observed in the current tree — don't mistake these for intentional patterns to copy: Observed in the current tree — don't mistake these for intentional patterns to copy:
- `pnpm check` is not clean. **Baseline as of 2026-08-13: 30 errors, 1 warning, 8 files**, most of - **`pnpm check` is clean as of 2026-08-17 and CI blocks on it** — 0 errors, 0 warnings. Any error
them in `src/routes/api/[...path]/+server.ts` (see below). Get your own baseline before assuming you see is yours. `src/lib/schema/openapi/schema.d.ts` is
an error is yours. `src/lib/schema/openapi/schema.d.ts` was regenerated on that date and is current; regenerate it after any backend API change, with
current; regenerate it again after any backend API change, with
`pnpm exec openapi-typescript http://localhost:8000/schema/openapi.json -o src/lib/schema/openapi/schema.d.ts` `pnpm exec openapi-typescript http://localhost:8000/schema/openapi.json -o src/lib/schema/openapi/schema.d.ts`
against a backend running **your** branch — a stale server silently writes a stale file. against a backend running **your** branch — a stale server silently writes a stale file.
- **Prettier is clean and CI blocks on it** — run `pnpm format` before finishing. Two things it - **Prettier is clean and CI blocks on it** — run `pnpm format` before finishing. Two things it
must not touch are in `.prettierignore`: the vendored foliate-js, and must not touch are in `.prettierignore`: the vendored foliate-js, and
`src/lib/schema/openapi/schema.d.ts`, which `openapi-typescript` regenerates in its own style. `src/lib/schema/openapi/schema.d.ts`, which `openapi-typescript` regenerates in its own style.
- `pnpm exec eslint .` reports **87 pre-existing errors as of 2026-08-17**, all in `src/`. CI runs - `pnpm exec eslint .` reports **2 errors as of 2026-08-17**, both `svelte/no-at-html-tags` in
it non-blocking (`continue-on-error`) until that reaches zero. `static/pdfjs/` is ignored `collapsible-text.svelte`. They are a genuine XSS hole, not a lint nit — see `TODO.md`. CI runs
eslint non-blocking (`continue-on-error`) only until that is fixed. `static/pdfjs/` is ignored
alongside `src/lib/vendor/` — it is vendored too, and linting it produced 1717 further errors. alongside `src/lib/vendor/` — it is vendored too, and linting it produced 1717 further errors.
- Two rules are off for `**/*.svelte` in `eslint.config.js` because they predate runes and
misread them: `no-useless-assignment` (every `$bindable()` default) and
`@typescript-eslint/no-unused-expressions` (a bare `book;` declaring an `$effect` dependency).
A leading underscore marks an intentionally unused binding.
- `src/routes/api/[...path]/+server.ts` — all four handlers are annotated `RequestHandler` while the - `src/routes/api/[...path]/+server.ts` — all four handlers are annotated `RequestHandler` while the
import of that type is commented out at line 4. It also buffers whole **responses** with import of that type is commented out at line 4. It also buffers whole **responses** with
`arrayBuffer()` and forwards no `Range` header, so book downloads are not streamed. **Requests** `arrayBuffer()` and forwards no `Range` header, so book downloads are not streamed. **Requests**
+20 -2
View File
@@ -28,7 +28,18 @@ export default defineConfig(
rules: { rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off' 'no-undef': 'off',
// A leading underscore marks a binding that exists to hold a position — a callback
// parameter the signature requires, or the discarded half of a destructure.
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_'
}
]
} }
}, },
{ {
@@ -41,8 +52,15 @@ export default defineConfig(
// no-useless-assignment joined eslint:recommended in ESLint 10, and its flow analysis // no-useless-assignment joined eslint:recommended in ESLint 10, and its flow analysis
// does not model runes: it reads `let { ref = $bindable(null) } = $props()` as a value // does not model runes: it reads `let { ref = $bindable(null) } = $props()` as a value
// that is never read. Deleting the default, as it suggests, breaks the binding. // that is never read. Deleting the default, as it suggests, breaks the binding.
//
// no-unused-expressions is off for the same reason: a bare `book;` inside an $effect is
// how a reactive dependency is declared when the read would otherwise be untracked.
// Removing the statement stops the effect re-running.
files: ['**/*.svelte'], files: ['**/*.svelte'],
rules: { 'no-useless-assignment': 'off' } rules: {
'no-useless-assignment': 'off',
'@typescript-eslint/no-unused-expressions': 'off'
}
}, },
{ {
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
+6 -6
View File
@@ -19,10 +19,10 @@
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@iconify/svelte": "^5.2.2", "@iconify/svelte": "^5.2.2",
"@internationalized/date": "^3.12.3", "@internationalized/date": "^3.12.3",
"@lucide/svelte": "^0.544.0", "@lucide/svelte": "^1.31.0",
"@sveltejs/adapter-node": "^5.5.7", "@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.70.2", "@sveltejs/kit": "^2.70.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^7.3.0",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@types/node": "^26.2.0", "@types/node": "^26.2.0",
"bits-ui": "^2.18.1", "bits-ui": "^2.18.1",
@@ -31,10 +31,10 @@
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.23.0", "eslint-plugin-svelte": "^3.23.0",
"globals": "^17.11.0", "globals": "^17.11.0",
"jsrepo": "^2.5.2", "jsrepo": "^3.8.1",
"openapi-typescript": "^7.13.0", "openapi-typescript": "^7.13.0",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"prettier-plugin-svelte": "^3.5.2", "prettier-plugin-svelte": "^4.1.1",
"prettier-plugin-tailwindcss": "^0.8.1", "prettier-plugin-tailwindcss": "^0.8.1",
"svelte": "^5.56.9", "svelte": "^5.56.9",
"svelte-check": "^4.7.6", "svelte-check": "^4.7.6",
@@ -43,9 +43,9 @@
"tailwind-variants": "^3.3.1", "tailwind-variants": "^3.3.1",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.67.0", "typescript-eslint": "^8.67.0",
"vite": "^7.3.6" "vite": "^8.2.1"
}, },
"dependencies": { "dependencies": {
"construct-style-sheets-polyfill": "^3.1.0", "construct-style-sheets-polyfill": "^3.1.0",
+645 -1048
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -4,7 +4,7 @@ import { BACKEND_API_URL } from '$lib/server/config';
import { invalid, redirect } from '@sveltejs/kit'; import { invalid, redirect } from '@sveltejs/kit';
export const login = form(loginSchema, async (data, issue) => { export const login = form(loginSchema, async (data, issue) => {
const { cookies, locals } = getRequestEvent(); const { cookies } = getRequestEvent();
// Create URL-encoded form data // Create URL-encoded form data
const formData = new URLSearchParams(); const formData = new URLSearchParams();
+1 -1
View File
@@ -25,6 +25,6 @@ export const createLibrary = form(libraryCreateSchema, async (data) => {
return await response.json(); return await response.json();
}); });
export const deleteLibrary = query('unchecked', async (data) => { export const deleteLibrary = query('unchecked', async (_data) => {
throw new Error('Not implemented'); throw new Error('Not implemented');
}); });
@@ -3,8 +3,6 @@
import { Checkbox } from '$lib/components/ui/checkbox/index.js'; import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js';
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
let { let {
open = $bindable(), open = $bindable(),
@@ -13,12 +11,9 @@
}: { }: {
open: boolean; open: boolean;
title?: string; title?: string;
deleteFn: (deleteFiles: boolean) => {}; deleteFn: (deleteFiles: boolean) => void | Promise<void>;
} = $props(); } = $props();
const selectedState = getBookSelectionState();
const bookOps = getBookOperationsState();
let deleteFiles = $state(false); let deleteFiles = $state(false);
</script> </script>
@@ -39,7 +39,7 @@
<Field.Field> <Field.Field>
<Field.Label for="name">Device name</Field.Label> <Field.Label for="name">Device name</Field.Label>
<Input {...createDevice.fields.name.as('text')} placeholder="e.g. Kindle Paperwhite" /> <Input {...createDevice.fields.name.as('text')} placeholder="e.g. Kindle Paperwhite" />
{#each createDevice.fields.name.issues() ?? [] as issue} {#each createDevice.fields.name.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -128,7 +128,7 @@
<Field.Field class="col-span-full"> <Field.Field class="col-span-full">
<Field.Label for="title">Title</Field.Label> <Field.Label for="title">Title</Field.Label>
<Input {...updateBookMetadata.fields.title.as('text')} /> <Input {...updateBookMetadata.fields.title.as('text')} />
{#each updateBookMetadata.fields.title.issues() ?? [] as issue} {#each updateBookMetadata.fields.title.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -136,7 +136,7 @@
<Field.Field> <Field.Field>
<Field.Label for="subtitle">Subtitle</Field.Label> <Field.Label for="subtitle">Subtitle</Field.Label>
<Input {...updateBookMetadata.fields.subtitle.as('text')} /> <Input {...updateBookMetadata.fields.subtitle.as('text')} />
{#each updateBookMetadata.fields.subtitle.issues() ?? [] as issue} {#each updateBookMetadata.fields.subtitle.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -144,7 +144,7 @@
<Field.Field> <Field.Field>
<Field.Label for="edition">Edition</Field.Label> <Field.Label for="edition">Edition</Field.Label>
<Input {...updateBookMetadata.fields.edition.as('number')} /> <Input {...updateBookMetadata.fields.edition.as('number')} />
{#each updateBookMetadata.fields.edition.issues() ?? [] as issue} {#each updateBookMetadata.fields.edition.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -152,7 +152,7 @@
<Field.Field> <Field.Field>
<Field.Label for="series">Series</Field.Label> <Field.Label for="series">Series</Field.Label>
<Input {...updateBookMetadata.fields.series.as('text')} /> <Input {...updateBookMetadata.fields.series.as('text')} />
{#each updateBookMetadata.fields.series.issues() ?? [] as issue} {#each updateBookMetadata.fields.series.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -161,7 +161,7 @@
<Field.Field> <Field.Field>
<Field.Label for="series_position">No.</Field.Label> <Field.Label for="series_position">No.</Field.Label>
<Input {...updateBookMetadata.fields.series_position.as('text')} /> <Input {...updateBookMetadata.fields.series_position.as('text')} />
{#each updateBookMetadata.fields.series_position.issues() ?? [] as issue} {#each updateBookMetadata.fields.series_position.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -169,7 +169,7 @@
<Field.Field> <Field.Field>
<Field.Label for="language">Language</Field.Label> <Field.Label for="language">Language</Field.Label>
<Input {...updateBookMetadata.fields.language.as('text')} /> <Input {...updateBookMetadata.fields.language.as('text')} />
{#each updateBookMetadata.fields.language.issues() ?? [] as issue} {#each updateBookMetadata.fields.language.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -185,10 +185,10 @@
placeholder="Add an author" placeholder="Add an author"
class="min-h-10 p-2 text-sm" class="min-h-10 p-2 text-sm"
/> />
{#each authors as author} {#each authors as author, i (i)}
<input class="hidden" {...updateBookMetadata.fields.authors.as('checkbox', author)} /> <input class="hidden" {...updateBookMetadata.fields.authors.as('checkbox', author)} />
{/each} {/each}
{#each updateBookMetadata.fields.authors.issues() ?? [] as issue} {#each updateBookMetadata.fields.authors.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -201,10 +201,10 @@
placeholder="Add a tag" placeholder="Add a tag"
class="min-h-10 p-2 text-sm" class="min-h-10 p-2 text-sm"
/> />
{#each tags as tag} {#each tags as tag, i (i)}
<input class="hidden" {...updateBookMetadata.fields.tags.as('checkbox', tag)} /> <input class="hidden" {...updateBookMetadata.fields.tags.as('checkbox', tag)} />
{/each} {/each}
{#each updateBookMetadata.fields.tags.issues() ?? [] as issue} {#each updateBookMetadata.fields.tags.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -214,7 +214,7 @@
<Field.Field> <Field.Field>
<Field.Label for="publisher">Publisher</Field.Label> <Field.Label for="publisher">Publisher</Field.Label>
<Input {...updateBookMetadata.fields.publisher.as('text')} /> <Input {...updateBookMetadata.fields.publisher.as('text')} />
{#each updateBookMetadata.fields.publisher.issues() ?? [] as issue} {#each updateBookMetadata.fields.publisher.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -223,7 +223,7 @@
<Field.Field> <Field.Field>
<Field.Label for="published_date">Published</Field.Label> <Field.Label for="published_date">Published</Field.Label>
<Input {...updateBookMetadata.fields.published_date.as('date')} /> <Input {...updateBookMetadata.fields.published_date.as('date')} />
{#each updateBookMetadata.fields.published_date.issues() ?? [] as issue} {#each updateBookMetadata.fields.published_date.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -231,7 +231,7 @@
<Field.Field> <Field.Field>
<Field.Label for="pages">Pages</Field.Label> <Field.Label for="pages">Pages</Field.Label>
<Input {...updateBookMetadata.fields.pages.as('number')} /> <Input {...updateBookMetadata.fields.pages.as('number')} />
{#each updateBookMetadata.fields.pages.issues() ?? [] as issue} {#each updateBookMetadata.fields.pages.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -240,7 +240,7 @@
<Field.Field class="col-span-full"> <Field.Field class="col-span-full">
<Field.Label for="identifiers">Identifiers</Field.Label> <Field.Label for="identifiers">Identifiers</Field.Label>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#each identifierKeys as _, idx} {#each identifierKeys as _, idx (idx)}
<div class="grid grid-cols-[1fr_1.6fr_auto] gap-2"> <div class="grid grid-cols-[1fr_1.6fr_auto] gap-2">
<Input bind:value={identifierKeys[idx]} placeholder="ISBN, DOI…" /> <Input bind:value={identifierKeys[idx]} placeholder="ISBN, DOI…" />
<Input bind:value={identifierValues[idx]} placeholder="Value" /> <Input bind:value={identifierValues[idx]} placeholder="Value" />
@@ -271,7 +271,7 @@
<Field.Field class="col-span-full"> <Field.Field class="col-span-full">
<Field.Label for="description" class="sr-only">Description</Field.Label> <Field.Label for="description" class="sr-only">Description</Field.Label>
<Textarea {...updateBookMetadata.fields.description.as('text')} rows={6} /> <Textarea {...updateBookMetadata.fields.description.as('text')} rows={6} />
{#each updateBookMetadata.fields.description.issues() ?? [] as issue} {#each updateBookMetadata.fields.description.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -56,7 +56,7 @@
<Field.Field> <Field.Field>
<Field.Label for="name">Library name</Field.Label> <Field.Label for="name">Library name</Field.Label>
<Input {...createLibrary.fields.name.as('text')} /> <Input {...createLibrary.fields.name.as('text')} />
{#each createLibrary.fields.name.issues() ?? [] as issue} {#each createLibrary.fields.name.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -73,7 +73,7 @@
<Field.Field> <Field.Field>
<Field.Label for="description">Description</Field.Label> <Field.Label for="description">Description</Field.Label>
<Textarea {...createLibrary.fields.description.as('text')} /> <Textarea {...createLibrary.fields.description.as('text')} />
{#each createLibrary.fields.description.issues() ?? [] as issue} {#each createLibrary.fields.description.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -82,7 +82,7 @@
<Field.Field> <Field.Field>
<Field.Label for="root_path">Root Path</Field.Label> <Field.Label for="root_path">Root Path</Field.Label>
<Input {...createLibrary.fields.root_path.as('text')} /> <Input {...createLibrary.fields.root_path.as('text')} />
{#each createLibrary.fields.root_path.issues() ?? [] as issue} {#each createLibrary.fields.root_path.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
<Field.Description class="text-xs"> <Field.Description class="text-xs">
@@ -94,7 +94,7 @@
<Field.Field> <Field.Field>
<Field.Label for="path_template">Path Template</Field.Label> <Field.Label for="path_template">Path Template</Field.Label>
<Input {...createLibrary.fields.path_template.as('text')} /> <Input {...createLibrary.fields.path_template.as('text')} />
{#each createLibrary.fields.path_template.issues() ?? [] as issue} {#each createLibrary.fields.path_template.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
<Field.Description class="text-xs"> <Field.Description class="text-xs">
@@ -21,7 +21,7 @@
<Field.Field> <Field.Field>
<Field.Label for="email">Email</Field.Label> <Field.Label for="email">Email</Field.Label>
<Input {...login.fields.email.as('email')} /> <Input {...login.fields.email.as('email')} />
{#each login.fields.email.issues() ?? [] as issue} {#each login.fields.email.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -30,7 +30,7 @@
<Field.Field> <Field.Field>
<Field.Label for="password">Password</Field.Label> <Field.Label for="password">Password</Field.Label>
<Input {...login.fields.password.as('password')} /> <Input {...login.fields.password.as('password')} />
{#each login.fields.password.issues() ?? [] as issue} {#each login.fields.password.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -47,7 +47,7 @@
<Field.Field> <Field.Field>
<Field.Label for="email">Email</Field.Label> <Field.Label for="email">Email</Field.Label>
<Input {...signup.fields.email.as('email')} /> <Input {...signup.fields.email.as('email')} />
{#each signup.fields.email.issues() ?? [] as issue} {#each signup.fields.email.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -56,7 +56,7 @@
<Field.Field> <Field.Field>
<Field.Label for="password">Password</Field.Label> <Field.Label for="password">Password</Field.Label>
<Input {...signup.fields.password.as('password')} /> <Input {...signup.fields.password.as('password')} />
{#each signup.fields.password.issues() ?? [] as issue} {#each signup.fields.password.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -65,7 +65,7 @@
<Field.Field> <Field.Field>
<Field.Label for="confirmPassword">Confirm Password</Field.Label> <Field.Label for="confirmPassword">Confirm Password</Field.Label>
<Input {...signup.fields.confirmPassword.as('password')} /> <Input {...signup.fields.confirmPassword.as('password')} />
{#each signup.fields.confirmPassword.issues() ?? [] as issue} {#each signup.fields.confirmPassword.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -3,7 +3,7 @@
import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/index.js'; import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import { Badge } from '$lib/components/ui/badge/index.js'; import { Badge } from '$lib/components/ui/badge/index.js';
import { getLibraryState, LibraryState } from '$lib/state/library.svelte'; import { getLibraryState } from '$lib/state/library.svelte';
import { LIBRARY_ICONS } from '$lib/components/ui/icon-picker/index.js'; import { LIBRARY_ICONS } from '$lib/components/ui/icon-picker/index.js';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down'; import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import PlusIcon from '@lucide/svelte/icons/plus'; import PlusIcon from '@lucide/svelte/icons/plus';
@@ -53,9 +53,9 @@
} }
} }
function handleClick(e: any) { function handleClick(e: Event & { currentTarget: HTMLElement }) {
open = true; open = true;
e.target.blur(); e.currentTarget.blur();
} }
</script> </script>
@@ -93,7 +93,7 @@
</div> </div>
{:else} {:else}
<Command.List class="max-h-[600px]"> <Command.List class="max-h-[600px]">
{#if searchResult?.items.length > 0} {#if (searchResult?.items?.length ?? 0) > 0}
<Command.Group heading="Books"> <Command.Group heading="Books">
{#each searchResult?.items as book (book.id)} {#each searchResult?.items as book (book.id)}
<Command.Item <Command.Item
@@ -121,7 +121,7 @@
{#if book.authors.length > 0} {#if book.authors.length > 0}
<span class="line-clamp-1 w-full text-sm text-muted-foreground"> <span class="line-clamp-1 w-full text-sm text-muted-foreground">
by by
{#each book.authors as author} {#each book.authors as author (author.id)}
<a <a
href="{resolve('/(root)/(library)/library/[libraryId]/view', { href="{resolve('/(root)/(library)/library/[libraryId]/view', {
libraryId: String(libraryState.activeLibrary!.id) libraryId: String(libraryState.activeLibrary!.id)
@@ -132,7 +132,7 @@
</span> </span>
{/if} {/if}
<div class="mt-1 ml-[-1.5] flex"> <div class="mt-1 ml-[-1.5] flex">
{#each book.tags as tag} {#each book.tags as tag (tag.id)}
<Badge class="scale-75">{tag.name}</Badge> <Badge class="scale-75">{tag.name}</Badge>
{/each} {/each}
</div> </div>
@@ -185,7 +185,6 @@
type="file" type="file"
onchange={change} onchange={change}
webkitdirectory={directory} webkitdirectory={directory}
{directory}
class="hidden" class="hidden"
/> />
</label> </label>
@@ -37,7 +37,7 @@
</script> </script>
<Popover.Root bind:open> <Popover.Root bind:open>
<Popover.Trigger asChild> <Popover.Trigger>
{#snippet child({ props })} {#snippet child({ props })}
<Button variant="outline" size="icon" class="h-9 w-9" {...props}> <Button variant="outline" size="icon" class="h-9 w-9" {...props}>
<SelectedIconComponent class="size-4" /> <SelectedIconComponent class="size-4" />
@@ -36,7 +36,6 @@
$effect(() => { $effect(() => {
// whenever input value changes reset invalid // whenever input value changes reset invalid
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
inputValue; inputValue;
untrack(() => { untrack(() => {
@@ -19,5 +19,4 @@
className className
)} )}
bind:value bind:value
{...restProps} {...restProps}></textarea>
></textarea>
@@ -4,7 +4,6 @@
import type { Book } from '$lib/schema'; import type { Book } from '$lib/schema';
import BookThumbnail from './book-thumbnail.svelte'; import BookThumbnail from './book-thumbnail.svelte';
import { goto } from '$app/navigation';
import { getBookSelectionState } from '$lib/state/bookSelection.svelte'; import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
import { getBookOperationsState } from '$lib/state/bookOperations.svelte'; import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
import { getLibraryState } from '$lib/state/library.svelte'; import { getLibraryState } from '$lib/state/library.svelte';
@@ -1,8 +1,10 @@
<script> <script>
let { src, fallback = '/images/default_cover.jpg', class: className = '' } = $props(); let { src, fallback = '/images/default_cover.jpg', class: className = '' } = $props();
async function handleError(e) { /** @param {Event} e */
e.target.src = fallback; function handleError(e) {
const img = /** @type {HTMLImageElement} */ (e.currentTarget);
img.src = fallback;
} }
</script> </script>
@@ -2,7 +2,6 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import * as Table from '$lib/components/ui/table/index'; import * as Table from '$lib/components/ui/table/index';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
import * as Tooltip from '$lib/components/ui/tooltip/index';
import { Checkbox } from '$lib/components/ui/checkbox/index'; import { Checkbox } from '$lib/components/ui/checkbox/index';
import { Badge } from '$lib/components/ui/badge/index'; import { Badge } from '$lib/components/ui/badge/index';
import { buttonVariants } from '$lib/components/ui/button/index.js'; import { buttonVariants } from '$lib/components/ui/button/index.js';
@@ -2,12 +2,11 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import BookImage from './book-image.svelte'; import BookImage from './book-image.svelte';
import GeneratedCover from './generated-cover.svelte'; import GeneratedCover from './generated-cover.svelte';
import { Progress } from '$lib/components/ui/progress/index';
import { getLibraryState } from '$lib/state/library.svelte'; import { getLibraryState } from '$lib/state/library.svelte';
import { getBookSelectionState } from '$lib/state/bookSelection.svelte'; import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
import type { Book } from '$lib/schema'; import type { Book } from '$lib/schema';
let { book, class: className = '', ...rest }: { book: Book; class?: string } = $props(); let { book, class: className = '' }: { book: Book; class?: string } = $props();
const selectionState = getBookSelectionState(); const selectionState = getBookSelectionState();
const libraryState = getLibraryState(); const libraryState = getLibraryState();
@@ -78,7 +77,7 @@
<!-- Authors list --> <!-- Authors list -->
<p class="line-clamp-1 w-full text-xs text-muted-foreground"> <p class="line-clamp-1 w-full text-xs text-muted-foreground">
{#each book.authors as author} {#each book.authors as author (author.id)}
<a <a
href="{resolve('/(root)/(library)/library/[libraryId]/view', { href="{resolve('/(root)/(library)/library/[libraryId]/view', {
libraryId: String(libraryState.activeLibrary?.id ?? '') libraryId: String(libraryState.activeLibrary?.id ?? '')
@@ -30,7 +30,7 @@
<Collapsible.Content class="h-full max-h-128 overflow-y-auto"> <Collapsible.Content class="h-full max-h-128 overflow-y-auto">
<Sidebar.GroupContent> <Sidebar.GroupContent>
<Sidebar.Menu> <Sidebar.Menu>
{#each filter.items as item, index (item.id)} {#each filter.items as item (item.id)}
<Sidebar.MenuItem <Sidebar.MenuItem
onclick={() => bookCollection.toggleFilter(filter.value, item.id.toString())} onclick={() => bookCollection.toggleFilter(filter.value, item.id.toString())}
> >
@@ -25,7 +25,7 @@
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content class="w-40"> <DropdownMenu.Content class="w-40">
<DropdownMenu.Group> <DropdownMenu.Group>
{#each bookCollection.sortOptions as sortProp} {#each bookCollection.sortOptions as sortProp (sortProp.value)}
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => bookCollection.updateSort(sortProp.value)} onSelect={() => bookCollection.updateSort(sortProp.value)}
class={bookCollection.orderBy === sortProp.value ? 'bg-muted' : ''} class={bookCollection.orderBy === sortProp.value ? 'bg-muted' : ''}
-2
View File
@@ -4,8 +4,6 @@ const emailSchema = z
.email('Please enter a valid email address') .email('Please enter a valid email address')
.max(255, 'Email address cannot exceed 255 characters'); .max(255, 'Email address cannot exceed 255 characters');
const usernameSchema = z.string().min(1, 'Email is required');
const basePasswordSchema = z.string().min(1, 'Password is required'); const basePasswordSchema = z.string().min(1, 'Password is required');
const strongPasswordSchema = z.string().min(8, 'Password must be at least 8 characters'); const strongPasswordSchema = z.string().min(8, 'Password must be at least 8 characters');
+1 -3
View File
@@ -1,7 +1,5 @@
import { z } from 'zod';
import type { components } from './openapi/schema'; import type { components } from './openapi/schema';
import { commonQuerySchema, stringArrayCoerce, stringCoerce } from './common'; import { commonQuerySchema, stringArrayCoerce } from './common';
export type Author = components['schemas']['AuthorRead']; export type Author = components['schemas']['AuthorRead'];
+1 -1
View File
@@ -67,7 +67,7 @@ const identifiersSchema = z
.transform((str, ctx) => { .transform((str, ctx) => {
try { try {
return JSON.parse(str); return JSON.parse(str);
} catch (e) { } catch {
ctx.addIssue({ ctx.addIssue({
code: 'custom', code: 'custom',
message: 'Must be a valid JSON string' message: 'Must be a valid JSON string'
-2
View File
@@ -1,5 +1,3 @@
import { z } from 'zod';
import type { components } from './openapi/schema'; import type { components } from './openapi/schema';
import { commonQuerySchema, stringArrayCoerce } from './common'; import { commonQuerySchema, stringArrayCoerce } from './common';
-2
View File
@@ -1,5 +1,3 @@
import { z } from 'zod';
import type { components } from './openapi/schema'; import type { components } from './openapi/schema';
import { commonQuerySchema, stringArrayCoerce } from './common'; import { commonQuerySchema, stringArrayCoerce } from './common';
+3 -3
View File
@@ -21,7 +21,7 @@ export class ApiClient {
return this.request(endpoint); return this.request(endpoint);
} }
async post(endpoint: string, data: any): Promise<Response> { async post(endpoint: string, data: unknown): Promise<Response> {
return this.request(endpoint, { return this.request(endpoint, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -48,7 +48,7 @@ export class ApiClient {
}); });
} }
async put(endpoint: string, data: any): Promise<Response> { async put(endpoint: string, data: unknown): Promise<Response> {
return this.request(endpoint, { return this.request(endpoint, {
method: 'PUT', method: 'PUT',
headers: { headers: {
@@ -71,7 +71,7 @@ export class ApiClient {
}); });
} }
async patch(endpoint: string, data: any): Promise<Response> { async patch(endpoint: string, data: unknown): Promise<Response> {
return this.request(endpoint, { return this.request(endpoint, {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
@@ -1,5 +1,5 @@
import { getContext, setContext } from 'svelte'; import { getContext, setContext } from 'svelte';
import { goto, pushState, replaceState } from '$app/navigation'; import { goto, replaceState } from '$app/navigation';
import type { Book, DynamicFilterData, FilterOption, PaginatedResponse } from '$lib/schema'; import type { Book, DynamicFilterData, FilterOption, PaginatedResponse } from '$lib/schema';
import { page } from '$app/state'; import { page } from '$app/state';
import { BookOperationsState } from './bookOperations.svelte'; import { BookOperationsState } from './bookOperations.svelte';
@@ -105,6 +105,7 @@ export class BookCollectionState {
this.view = next; this.view = next;
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local temporary, not reactive state
const url = new URL(page.url); const url = new URL(page.url);
url.searchParams.set('view', next); url.searchParams.set('view', next);
// Not a route to resolve — this is the current URL with one query param // Not a route to resolve — this is the current URL with one query param
@@ -138,6 +139,7 @@ export class BookCollectionState {
updateSearchParams() { updateSearchParams() {
// Update URL // Update URL
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local temporary, not reactive state
const url = new URL(window.location.href); const url = new URL(window.location.href);
Object.entries(this.filters).forEach(([filter, values]) => { Object.entries(this.filters).forEach(([filter, values]) => {
@@ -215,6 +217,7 @@ export class BookCollectionState {
// and skips every page in between. // and skips every page in between.
this.currentBookPage = 1; this.currentBookPage = 1;
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local temporary, not reactive state
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
// Re-read sort and filter state from URL // Re-read sort and filter state from URL
@@ -16,7 +16,7 @@ export class BookOperationsState {
// Delete dialog related state // Delete dialog related state
deleteDialogOpen = $state(false); deleteDialogOpen = $state(false);
deleteDialogTitle = $state('Delete books?'); deleteDialogTitle = $state('Delete books?');
deleteFn = $state((deleteFiles: boolean) => {}); deleteFn = $state((_deleteFiles: boolean) => {});
// Edit dialog related state // Edit dialog related state
editDialogOpen = $state(false); editDialogOpen = $state(false);
@@ -131,6 +131,7 @@ export class BookOperationsState {
async downloadBooks(bookIds: string[] | number[], filename?: string) { async downloadBooks(bookIds: string[] | number[], filename?: string) {
// Construct the download URL // Construct the download URL
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local temporary, not reactive state
const downloadUrl = new URL(`/api/books/download`, page.url.origin); const downloadUrl = new URL(`/api/books/download`, page.url.origin);
downloadUrl.searchParams.set('library_id', this.libraryId); downloadUrl.searchParams.set('library_id', this.libraryId);
bookIds.forEach((id) => { bookIds.forEach((id) => {
@@ -142,6 +143,7 @@ export class BookOperationsState {
async downloadBookFile(bookId: number, fileId: number, filename: string) { async downloadBookFile(bookId: number, fileId: number, filename: string) {
// Construct the download URL // Construct the download URL
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local temporary, not reactive state
const downloadUrl = new URL(`/api/books/download/${bookId}/${fileId}`, page.url.origin); const downloadUrl = new URL(`/api/books/download/${bookId}/${fileId}`, page.url.origin);
this.download(downloadUrl, filename); this.download(downloadUrl, filename);
} }
+5 -4
View File
@@ -1,4 +1,4 @@
import { goto, invalidate } from '$app/navigation'; import { invalidate } from '$app/navigation';
import { addBooksToShelf, createBookshelf, listBookshelves, removeBooksFromShelf } from '$lib/api'; import { addBooksToShelf, createBookshelf, listBookshelves, removeBooksFromShelf } from '$lib/api';
import type { Book, Bookshelf } from '$lib/schema'; import type { Book, Bookshelf } from '$lib/schema';
import { getContext, setContext } from 'svelte'; import { getContext, setContext } from 'svelte';
@@ -22,7 +22,7 @@ export class BookshelfState {
async fetchBookshelves(libraryId: string) { async fetchBookshelves(libraryId: string) {
try { try {
let paginatedBookshelves = await listBookshelves({ const paginatedBookshelves = await listBookshelves({
libraries: [libraryId] libraries: [libraryId]
}); });
@@ -34,7 +34,7 @@ export class BookshelfState {
async addBookshelf(name: string, libraryId?: string | number, booksToAdd?: string[] | number[]) { async addBookshelf(name: string, libraryId?: string | number, booksToAdd?: string[] | number[]) {
try { try {
let bookshelf = await createBookshelf({ const bookshelf = await createBookshelf({
title: name, title: name,
library_id: libraryId, library_id: libraryId,
book_ids: booksToAdd book_ids: booksToAdd
@@ -116,6 +116,7 @@ export class BookshelfState {
if (!bookshelves) return; if (!bookshelves) return;
// Create a Set of shelf IDs that need updating for efficient lookup // Create a Set of shelf IDs that need updating for efficient lookup
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local temporary, not reactive state
const shelfIdsToUpdate = new Set<number>(); const shelfIdsToUpdate = new Set<number>();
books.forEach((book) => { books.forEach((book) => {
book.lists.forEach((shelf) => { book.lists.forEach((shelf) => {
@@ -132,7 +133,7 @@ export class BookshelfState {
decrementBy++; decrementBy++;
} }
}); });
return { ...shelf, total: shelf.total - decrementBy }; return { ...shelf, total: (shelf.total ?? 0) - decrementBy };
} }
return shelf; return shelf;
}); });
+1 -1
View File
@@ -1,6 +1,6 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { goto, invalidate } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { deleteLibrary } from '$lib/api'; import { deleteLibrary } from '$lib/api';
import type { Library } from '$lib/schema'; import type { Library } from '$lib/schema';
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import BookDelete from '$lib/components/forms/book-delete.svelte'; import BookDelete from '$lib/components/forms/book-delete.svelte';
import { BookEdit } from '$lib/components/forms/edit-book'; import { BookEdit } from '$lib/components/forms/edit-book';
import { getBookOperationsState, setBookOperationsState } from '$lib/state/bookOperations.svelte'; import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
import { getLibraryState } from '$lib/state/library.svelte'; import { getLibraryState } from '$lib/state/library.svelte';
import { setBookSelectionState } from '$lib/state/bookSelection.svelte'; import { setBookSelectionState } from '$lib/state/bookSelection.svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
@@ -28,22 +28,16 @@
import { getBookOperationsState } from '$lib/state/bookOperations.svelte.js'; import { getBookOperationsState } from '$lib/state/bookOperations.svelte.js';
import { getBookshelfState } from '$lib/state/bookshelf.svelte.js'; import { getBookshelfState } from '$lib/state/bookshelf.svelte.js';
import { getLibraryState } from '$lib/state/library.svelte.js'; import { getLibraryState } from '$lib/state/library.svelte.js';
import { Progress } from '$lib/components/ui/progress/index.js';
import * as Accordion from '$lib/components/ui/accordion/index.js'; import * as Accordion from '$lib/components/ui/accordion/index.js';
import * as Table from '$lib/components/ui/table/index.js'; import * as Table from '$lib/components/ui/table/index.js';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js'; import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import ShelfCreateDialog from '$lib/components/forms/shelf-create-dialog.svelte'; import ShelfCreateDialog from '$lib/components/forms/shelf-create-dialog.svelte';
import { untrack } from 'svelte';
let { data } = $props(); let { data } = $props();
// Seeded once, then kept in sync by the effect below — untrack says so // Follows the loaded book, and stays assignable so an edit can update it locally
// explicitly rather than capturing the initial value and warning about it. // until the next navigation supplies a fresh one.
let book = $state(untrack(() => data.book)); let book = $derived(data.book);
$effect(() => {
book = data.book;
});
const bookOps = getBookOperationsState(); const bookOps = getBookOperationsState();
const libraryState = getLibraryState(); const libraryState = getLibraryState();
@@ -20,7 +20,13 @@
}; };
} = $props(); } = $props();
const { books, ...filterData } = data; // Seeds the collection state once, deliberately: it owns the list from here on, and the
// effect below feeds it later navigations. untrack says so, rather than reading props
// outside a closure and being warned about capturing only the initial value.
const { books, filterData } = untrack(() => {
const { books: initialBooks, ...rest } = data;
return { books: initialBooks, filterData: rest };
});
const bookOps = getBookOperationsState(); const bookOps = getBookOperationsState();
const bookCollection = setBookCollectionState(bookOps, books, filterData); const bookCollection = setBookCollectionState(bookOps, books, filterData);
+2 -4
View File
@@ -1,9 +1,7 @@
<script lang="ts"> <script lang="ts">
import { listBookshelves } from '$lib/api';
import AppSidebar from '$lib/components/layout/app-sidebar.svelte'; import AppSidebar from '$lib/components/layout/app-sidebar.svelte';
import SiteHeader from '$lib/components/layout/site-header.svelte'; import SiteHeader from '$lib/components/layout/site-header.svelte';
import UploadTray from '$lib/components/layout/upload-tray.svelte'; import UploadTray from '$lib/components/layout/upload-tray.svelte';
import { Loading } from '$lib/components/ui/command';
import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import type { Library, PaginatedResponse } from '$lib/schema'; import type { Library, PaginatedResponse } from '$lib/schema';
import { setBookOperationsState } from '$lib/state/bookOperations.svelte'; import { setBookOperationsState } from '$lib/state/bookOperations.svelte';
@@ -28,8 +26,8 @@
// init. `untrack` says that explicitly, instead of silently capturing the // init. `untrack` says that explicitly, instead of silently capturing the
// initial value and warning about it. // initial value and warning about it.
const libraryState = setLibraryState(untrack(() => data.libraries.items)); const libraryState = setLibraryState(untrack(() => data.libraries.items));
const bookshelfState = setBookshelfState(); setBookshelfState();
const bookOps = setBookOperationsState(libraryState.activeLibrary!.id); setBookOperationsState(libraryState.activeLibrary!.id);
const theme = setThemeState(untrack(() => data.theme)); const theme = setThemeState(untrack(() => data.theme));
// Set here rather than beside the upload dialog so a running import survives // Set here rather than beside the upload dialog so a running import survives
@@ -41,7 +41,7 @@
of libraries; the row above it is overflow-hidden, so without this a of libraries; the row above it is overflow-hidden, so without this a
long list is clipped rather than reachable. --> long list is clipped rather than reachable. -->
<nav class="flex w-48 shrink-0 flex-col gap-1 overflow-y-auto"> <nav class="flex w-48 shrink-0 flex-col gap-1 overflow-y-auto">
{#each items as item} {#each items as item (item.routeId)}
<a <a
href={resolve(item.routeId)} href={resolve(item.routeId)}
class="rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-muted {page class="rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-muted {page
+1 -3
View File
@@ -1,8 +1,6 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
export async function load({ parent }) { export async function load() {
const { libraries } = await parent();
// Immediately redirect to the account settings // Immediately redirect to the account settings
redirect(303, `/settings/account`); redirect(303, `/settings/account`);
} }
@@ -21,11 +21,12 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import type { Device } from '$lib/schema/device'; import type { Device } from '$lib/schema/device';
import { SvelteSet } from 'svelte/reactivity';
let { data } = $props(); let { data } = $props();
let createDialogOpen = $state(false); let createDialogOpen = $state(false);
let visibleApiKeys = $state<Set<string>>(new Set()); const visibleApiKeys = new SvelteSet<string>();
let deleteConfirmDevice = $state<Device | null>(null); let deleteConfirmDevice = $state<Device | null>(null);
let regenerateConfirmDevice = $state<Device | null>(null); let regenerateConfirmDevice = $state<Device | null>(null);
@@ -35,7 +36,6 @@
} else { } else {
visibleApiKeys.add(deviceId); visibleApiKeys.add(deviceId);
} }
visibleApiKeys = new Set(visibleApiKeys);
} }
function maskApiKey(apiKey: string): string { function maskApiKey(apiKey: string): string {
@@ -130,7 +130,7 @@
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#each data.devices as device} {#each data.devices as device (device.id)}
{@const isVisible = visibleApiKeys.has(String(device.id))} {@const isVisible = visibleApiKeys.has(String(device.id))}
<Table.Row class="h-14"> <Table.Row class="h-14">
<Table.Cell class="pl-4 font-medium"> <Table.Cell class="pl-4 font-medium">
@@ -31,7 +31,7 @@
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#each libraryState.libraries as library} {#each libraryState.libraries as library (library.id)}
<Table.Row class="h-14"> <Table.Row class="h-14">
<Table.Cell class="w-16 pl-4 text-center text-lg font-semibold" <Table.Cell class="w-16 pl-4 text-center text-lg font-semibold"
>{library.name[0]}</Table.Cell >{library.name[0]}</Table.Cell
+2 -2
View File
@@ -1,7 +1,7 @@
// src/routes/api/[...path]/+server.ts // src/routes/api/[...path]/+server.ts
import { BACKEND_API_URL } from '$lib/server/config'; import { BACKEND_API_URL } from '$lib/server/config';
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
// import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
// Helper function to handle both JSON and file responses // Helper function to handle both JSON and file responses
async function handleResponse(response: Response) { async function handleResponse(response: Response) {
@@ -137,7 +137,7 @@ export const DELETE: RequestHandler = async ({ params, locals, fetch, request, u
const headers = prepareRequest(locals, request); const headers = prepareRequest(locals, request);
// DELETE may or may not have a body // DELETE may or may not have a body
let options: RequestInit = { const options: RequestInit = {
method: 'DELETE', method: 'DELETE',
headers headers
}; };
-2
View File
@@ -1,9 +1,7 @@
<script lang="ts"> <script lang="ts">
import { replaceState } from '$app/navigation';
import LoginForm from '$lib/components/forms/login-form.svelte'; import LoginForm from '$lib/components/forms/login-form.svelte';
import SignupForm from '$lib/components/forms/signup-form.svelte'; import SignupForm from '$lib/components/forms/signup-form.svelte';
import * as Tabs from '$lib/components/ui/tabs/index.js'; import * as Tabs from '$lib/components/ui/tabs/index.js';
import { redirect } from '@sveltejs/kit';
let tabValue = $state('login'); let tabValue = $state('login');
</script> </script>