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.
#
# Blocking: the checks that are clean today — ruff format, ruff check, prettier, pytest.
# Non-blocking: eslint and svelte-check, which still report 87 and 30 pre-existing errors.
# Those need real code changes rather than a formatter, so they report without failing the
# build; drop the `continue-on-error` line from a step once its count reaches zero.
# Blocking: ruff format, ruff check, pytest, prettier and svelte-check.
# Non-blocking: eslint, which reports two `{@html}` XSS findings in collapsible-text.svelte.
# Those are a real vulnerability rather than a lint nit — book descriptions from EPUB files
# 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.
@@ -18,6 +19,30 @@ jobs:
backend:
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:
run:
working-directory: backend
@@ -36,13 +61,11 @@ jobs:
run: uv sync --locked
- name: Format
run: uv run ruff format --check src/
run: uv run ruff format --check src/ tests/
- 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
run: uv run pytest tests/ -q
@@ -77,4 +100,3 @@ jobs:
- name: Types
run: pnpm check
continue-on-error: true
+4 -3
View File
@@ -42,20 +42,21 @@ jobs:
working-directory: backend
run: |
uv sync --locked
uv run ruff format --check src/
uv run ruff check src/
uv run ruff format --check src/ tests/
uv run ruff check src/ tests/
uv run pytest tests/ -q
- uses: actions/setup-node@v4
with:
node-version: 24
- name: Frontend format
- name: Frontend format and types
working-directory: frontend
run: |
corepack enable
pnpm install --frozen-lockfile
pnpm exec prettier --check .
pnpm check
build:
needs: quality
+6 -4
View File
@@ -70,7 +70,7 @@ Backend (from `backend/`):
```bash
uv run litestar --app-dir src/chitai/ run --reload # dev server on :8000
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 upgrade
@@ -103,9 +103,11 @@ API docs are served by the running backend at `http://localhost:8000/schema/` (S
manually against a live backend).
- **Migrations are mandatory.** The app runs with `create_all=False`, so a model change without a
matching Alembic revision will not reach the database.
- **CI gates formatting, linting and tests.** `.gitea/workflows/ci.yml` runs on every push and pull
request: `ruff format --check`, `ruff check`, `pytest`, and `prettier --check` all **block**;
`eslint` and `pnpm check` report without failing until their pre-existing counts reach zero. A
- **CI gates formatting, linting, types and tests.** `.gitea/workflows/ci.yml` runs on every push
and pull request: `ruff format --check src/ tests/`, `ruff check src/ tests/`, `pytest`,
`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
`docs/ci-release-pipeline.md`.
- **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
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
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
### 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
**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-asyncio>=1.2.0",
"pytest-databases[postgres]>=0.15.0",
"ruff==0.15.14",
]
[tool.ruff.lint.per-file-ignores]
+2 -1
View File
@@ -4,7 +4,8 @@ pkgs.mkShell {
buildInputs = with pkgs; [
# Python development environment for Chitai
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
# postgres database
+2 -1
View File
@@ -2,6 +2,8 @@ from pathlib import Path
from uuid import uuid4
import pytest
from chitai import services
from advanced_alchemy.base import UUIDAuditBase
from litestar.testing import AsyncTestClient
from sqlalchemy import text
@@ -155,7 +157,6 @@ async def other_authenticated_client(
# Service fixtures
from chitai import services
@pytest.fixture
+43 -34
View File
@@ -1,6 +1,7 @@
import pytest
from httpx import AsyncClient
from pathlib import Path
from litestar.status_codes import HTTP_400_BAD_REQUEST
@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
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 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."""
# 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
book_data = response.json()
@@ -300,13 +303,13 @@ async def test_delete_book_metadata_only(
# Delete book without deleting files
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
# 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
@@ -317,7 +320,7 @@ async def test_delete_book_with_files(
# Delete book and files
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
@@ -330,7 +333,7 @@ async def test_delete_specific_book_files(
# Delete specific file
response = await populated_authenticated_client.delete(
f"/books/1/files?file_ids=1",
"/books/1/files?file_ids=1",
)
assert response.status_code == 204
@@ -347,7 +350,7 @@ async def test_update_reading_progress(
}
response = await populated_authenticated_client.post(
f"/books/progress/1",
"/books/progress/1",
json=progress_data,
)
@@ -423,7 +426,9 @@ async def test_create_books_groups_formats_within_one_folder(
) -> None:
"""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()
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", ("Metamorphosis/Metamorphosis.epub", epub, "application/epub+zip")),
@@ -534,7 +539,9 @@ class TestDuplicateHandling:
"files",
(
"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",
),
)
@@ -550,7 +557,9 @@ class TestDuplicateHandling:
"files",
(
"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",
),
)
@@ -736,7 +745,9 @@ class TestDuplicateBooks:
assert len(merged["files"]) == 2
# 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")
assert groups.json() == []
@@ -786,16 +797,6 @@ class TestDuplicateBooks:
# async def test_edit_book_metadata(authenticated_client: AsyncClient) -> None:
# 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:
@pytest.mark.parametrize(
@@ -849,8 +850,10 @@ class TestMetadataUpdates:
(
"authors", # Update with new authors
["New Author 1", "New Author 2"],
lambda data: {a["name"] for a in data["authors"]}
== {"New Author 1", "New Author 2"},
lambda data: (
{a["name"] for a in data["authors"]}
== {"New Author 1", "New Author 2"}
),
),
(
"authors", # Clear authors
@@ -860,8 +863,9 @@ class TestMetadataUpdates:
(
"tags", # Update with new tags
["Tag 1", "Tag 2", "Tag 3"],
lambda data: {t["name"] for t in data["tags"]}
== {"Tag 1", "Tag 2", "Tag 3"},
lambda data: (
{t["name"] for t in data["tags"]} == {"Tag 1", "Tag 2", "Tag 3"}
),
),
(
"tags", # Clear tags
@@ -881,8 +885,10 @@ class TestMetadataUpdates:
(
"identifiers", # Update with new identifiers
{"isbn-13": "978-1234567890", "doi": "10.example/id"},
lambda data: data["identifiers"]
== {"isbn-13": "978-1234567890", "doi": "10.example/id"},
lambda data: (
data["identifiers"]
== {"isbn-13": "978-1234567890", "doi": "10.example/id"}
),
),
(
"identifiers", # Clear identifiers
@@ -1053,7 +1059,7 @@ class TestMetadataUpdates:
result = response.json()
assert result[updated_field] == None
assert result[updated_field] is None
@pytest.mark.parametrize(
("updated_field"),
@@ -1215,7 +1221,9 @@ class TestFileManagement:
pytest.skip("Book has no files")
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
response = await populated_authenticated_client.delete(
@@ -1240,7 +1248,8 @@ class TestFileManagement:
book_data = add_response.json()
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
response = await populated_authenticated_client.delete(
@@ -1295,7 +1304,9 @@ class TestUnnameableFormats:
self, authenticated_client: AsyncClient
) -> None:
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
@@ -1323,9 +1334,7 @@ class TestUnnameableFormats:
assert detail.status_code == 200
assert detail.json()["files"][0]["content_type"] is None
async def test_the_file_downloads(
self, authenticated_client: AsyncClient
) -> None:
async def test_the_file_downloads(self, authenticated_client: AsyncClient) -> None:
"""Litestar supplies its own media type when the row carries none."""
created = await authenticated_client.post(
"/books?library_id=1",
@@ -1,4 +1,3 @@
import pytest
from httpx import AsyncClient
@@ -200,7 +199,6 @@ async def test_remove_books_from_shelf(
"/books", params={"shelves": shelf_id}
)
assert books_response.status_code == 200
assert books_response.json()["total"] == 2
@@ -42,7 +42,9 @@ def fx_source(tmp_path: Path) -> Path:
cover=True,
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"])
return fixture.commit()
@@ -95,7 +97,8 @@ async def test_an_uploaded_library_imports(
authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None:
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
@@ -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)
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(
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
TEST_FILES = {
"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",
"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_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"}
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_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(
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["root_path"] == f"{tmp_path}/books"
assert result["path_template"] == "{author}/{title}"
assert result["read_only"] == False
assert result["read_only"] is False
assert result["description"] is None
+22 -7
View File
@@ -2,7 +2,10 @@
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")
@@ -23,12 +26,15 @@ def test_a_book_with_no_authors() -> None:
def test_a_series_adds_a_level_and_pads_the_position() -> None:
assert path_for(
assert (
path_for(
title="Persepolis Rising",
authors=["James S. A. Corey"],
series="The Expanse",
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:
@@ -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"])
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:
assert path_for(title="Split", authors=["A/B Collective"]) == (
ROOT / "A_B Collective" / "Split"
)
assert path_for(
title="Volume One", authors=["Someone"], series="Either/Or", series_position="1"
) == ROOT / "Someone" / "Either_Or" / "01 - Volume One"
assert (
path_for(
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:
+12 -4
View File
@@ -54,7 +54,8 @@ class TestNormalizeTitle:
assert normalize_title("The") == "the"
@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:
"""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:
"""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 normalize_identifier("urn:uuid", "3f2b1c4e-1111-2222-3333-444455556666") is None
assert (
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:
assert normalize_identifier("asin", "B000FC0PDA") == "asin:b000fc0pda"
@@ -187,7 +193,9 @@ class TestIsbnConversion:
assert isbn10_to_isbn13("043942089X") == "9780439420891"
@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
+13 -5
View File
@@ -22,7 +22,6 @@ class TestEpubExtractor:
assert metadata["published_date"] == date(year=2001, month=7, day=1)
EPUB = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
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:
"""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.
@@ -47,7 +48,9 @@ class TestIdentifierMerging:
}
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(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)
@pytest.mark.parametrize(
@@ -171,7 +176,10 @@ class TestEditionFromFiles:
"""The PDF fixture calls itself a 2nd edition in its own metadata title."""
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
@@ -25,7 +25,9 @@ PDF = DATA_FILES / "Calculus Made Easy - Silvanus Thompson.pdf"
def upload(path: Path, name: str | None = None) -> UploadFile:
"""An uploaded file carrying the bytes of one of the test fixtures."""
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,
file_data=path.read_bytes(),
)
@@ -538,9 +540,7 @@ class TestBookPathCollisions:
assert original.path != forced.path
paths = {
Path(book.path) / book.files[0].path for book in (original, forced)
}
paths = {Path(book.path) / book.files[0].path for book in (original, forced)}
assert len(paths) == 2
assert all(path.is_file() for path in paths)
@@ -581,7 +581,10 @@ class TestBookPathCollisions:
# Renamed onto the first book's author and title.
await books_service.update_book(
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,
)
@@ -743,7 +746,8 @@ class TestDuplicateBooks:
)
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]
@@ -834,16 +838,22 @@ class TestDuplicateBooks:
"series": "Foundation",
}
assert await books_service.find_duplicate_books(
assert (
await books_service.find_duplicate_books(
incoming | {"series_position": "2"}, test_library
) == []
)
== []
)
# The same volume, written a little differently, still matches.
assert len(
assert (
len(
await books_service.find_duplicate_books(
incoming | {"series_position": "1.0"}, test_library
)
) == 1
)
== 1
)
async def test_a_book_is_not_its_own_duplicate(
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.
"""
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(
books_service,
@@ -953,7 +966,9 @@ class TestAuthorNames:
"Franz Kafka.epub" is not a person.
"""
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,
)
@@ -1014,7 +1029,10 @@ class TestMergeBooks:
) -> None:
"""The survivor keeps its own fields unless the caller says otherwise."""
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(
books_service,
@@ -1030,7 +1048,11 @@ class TestMergeBooks:
assert merged.publisher is None
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(
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)
rows = (
(
await books_service.repository.session.execute(
select(m.BookProgress).where(m.BookProgress.book_id == keep.id)
)
).scalars().all()
)
.scalars()
.all()
)
assert [row.percentage for row in rows] == [0.6]
@@ -1112,13 +1138,23 @@ class TestMergeBooks:
) -> None:
"""Both books on one shelf must not leave the survivor linked to it twice."""
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(
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)
await session.commit()
session.add_all(
@@ -1131,13 +1167,21 @@ class TestMergeBooks:
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 = (
(
await books_service.repository.session.execute(
select(m.BookListLink).where(m.BookListLink.book_id == keep.id)
)
).scalars().all()
)
.scalars()
.all()
)
assert len(links) == 1
async def test_an_identifier_moves_only_under_a_name_the_survivor_lacks(
@@ -2,19 +2,12 @@
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from chitai.services import ShelfService
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 import models as m
@pytest.fixture
@@ -201,7 +201,9 @@ async def test_importing_twice_creates_nothing(
]
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)
@@ -223,7 +225,9 @@ async def test_a_file_the_catalogue_lists_but_disk_does_not(
await source.close()
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(
@@ -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}
)
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()
@@ -34,8 +34,8 @@ class TestLibraryServiceCRUD:
assert library.name == "Test Library"
assert library.root_path == library_path
assert library.path_template == "{author}/{title}"
assert library.description == None
assert library.read_only == False
assert library.description is None
assert library.read_only is False
# Check if directory was created
assert Path(library.root_path).is_dir()
@@ -56,8 +56,8 @@ class TestLibraryServiceCRUD:
read_only=False,
)
with pytest.raises(PermissionError) as exc_info:
library = await library_service.create(library_data)
with pytest.raises(PermissionError):
await library_service.create(library_data)
# Check if directory was created
assert not Path(library_path).exists()
@@ -86,8 +86,8 @@ class TestLibraryServiceCRUD:
assert library.name == "Test Library"
assert library.root_path == library_path
assert library.path_template == "{author}/{title}"
assert library.description == None
assert library.read_only == True
assert library.description is None
assert library.read_only is True
async def test_create_library_read_only_nonexistent_path(
self, library_service: LibraryService, tmp_path: Path
@@ -138,7 +138,7 @@ class TestLibraryServiceCRUD:
assert library.root_path == "./books"
assert library.path_template == "{author}/{title}"
assert library.description is None
assert library.read_only == False
assert library.read_only is False
# async def test_delete_library_keep_files(
# self, session: AsyncSession, library_service: LibraryService
@@ -21,7 +21,7 @@ class TestUserServiceAuthentication:
# Create a user with a known password
password = "password123"
user = m.User(email=f"test@example.com", password=password)
user = m.User(email="test@example.com", password=password)
session.add(user)
await session.commit()
@@ -52,7 +52,7 @@ class TestUserServiceAuthentication:
# Create user
password = "password123"
user = m.User(email=f"test@example.com", password=password)
user = m.User(email="test@example.com", password=password)
session.add(user)
await session.commit()
@@ -85,7 +85,7 @@ class TestUserServiceCRUD:
) -> None:
"""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)
await session.commit()
@@ -102,12 +102,12 @@ class TestUserServiceCRUD:
"""Test creating a new user with a duplicate email."""
# 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)
await session.commit()
# 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:
session.add(user)
+27
View File
@@ -261,6 +261,7 @@ dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-databases", extra = ["postgres"] },
{ name = "ruff" },
]
[package.metadata]
@@ -286,6 +287,7 @@ dev = [
{ name = "pytest", specifier = ">=8.4.2" },
{ name = "pytest-asyncio", specifier = ">=1.2.0" },
{ name = "pytest-databases", extras = ["postgres"], specifier = ">=0.15.0" },
{ name = "ruff", specifier = "==0.15.14" },
]
[[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" },
]
[[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]]
name = "six"
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
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
workflows on Gitea. `act_runner` in docker mode runs each job inside a container that has no
daemon of its own. Either run a `docker:dind` sidecar next to the runner and set
`DOCKER_HOST=tcp://docker:2376` (with TLS certs shared over a volume), or run the runner in host
mode with the socket mounted. The dind sidecar is the safer of the two — mounting the host socket
into job containers gives any workflow root on the runner host.
2. **The runner needs a Docker daemon, at an address the job can reach.** `act_runner` in docker
mode runs each job inside a container with no daemon of its own. Either run a `docker:dind`
sidecar next to the runner and set `DOCKER_HOST=tcp://docker:2376` (with TLS certs shared over a
volume), or run the runner in host mode. The dind sidecar is the safer of the two — mounting the
host socket 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.
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`,
+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:
- `pnpm check` is not clean. **Baseline as of 2026-08-13: 30 errors, 1 warning, 8 files**, most of
them in `src/routes/api/[...path]/+server.ts` (see below). Get your own baseline before assuming
an error is yours. `src/lib/schema/openapi/schema.d.ts` was regenerated on that date and is
current; regenerate it again after any backend API change, with
- **`pnpm check` is clean as of 2026-08-17 and CI blocks on it** — 0 errors, 0 warnings. Any error
you see is yours. `src/lib/schema/openapi/schema.d.ts` is
current; regenerate it after any backend API change, with
`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.
- **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
`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
it non-blocking (`continue-on-error`) until that reaches zero. `static/pdfjs/` is ignored
- `pnpm exec eslint .` reports **2 errors as of 2026-08-17**, both `svelte/no-at-html-tags` in
`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.
- 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
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**
+20 -2
View File
@@ -28,7 +28,18 @@ export default defineConfig(
rules: {
// 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
'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
// 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.
//
// 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'],
rules: { 'no-useless-assignment': 'off' }
rules: {
'no-useless-assignment': 'off',
'@typescript-eslint/no-unused-expressions': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
+6 -6
View File
@@ -19,10 +19,10 @@
"@eslint/js": "^10.0.1",
"@iconify/svelte": "^5.2.2",
"@internationalized/date": "^3.12.3",
"@lucide/svelte": "^0.544.0",
"@lucide/svelte": "^1.31.0",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.70.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@sveltejs/vite-plugin-svelte": "^7.3.0",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^26.2.0",
"bits-ui": "^2.18.1",
@@ -31,10 +31,10 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.23.0",
"globals": "^17.11.0",
"jsrepo": "^2.5.2",
"jsrepo": "^3.8.1",
"openapi-typescript": "^7.13.0",
"prettier": "^3.9.6",
"prettier-plugin-svelte": "^3.5.2",
"prettier-plugin-svelte": "^4.1.1",
"prettier-plugin-tailwindcss": "^0.8.1",
"svelte": "^5.56.9",
"svelte-check": "^4.7.6",
@@ -43,9 +43,9 @@
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0",
"vite": "^7.3.6"
"vite": "^8.2.1"
},
"dependencies": {
"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';
export const login = form(loginSchema, async (data, issue) => {
const { cookies, locals } = getRequestEvent();
const { cookies } = getRequestEvent();
// Create URL-encoded form data
const formData = new URLSearchParams();
+1 -1
View File
@@ -25,6 +25,6 @@ export const createLibrary = form(libraryCreateSchema, async (data) => {
return await response.json();
});
export const deleteLibrary = query('unchecked', async (data) => {
export const deleteLibrary = query('unchecked', async (_data) => {
throw new Error('Not implemented');
});
@@ -3,8 +3,6 @@
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { Button } from '$lib/components/ui/button/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 {
open = $bindable(),
@@ -13,12 +11,9 @@
}: {
open: boolean;
title?: string;
deleteFn: (deleteFiles: boolean) => {};
deleteFn: (deleteFiles: boolean) => void | Promise<void>;
} = $props();
const selectedState = getBookSelectionState();
const bookOps = getBookOperationsState();
let deleteFiles = $state(false);
</script>
@@ -39,7 +39,7 @@
<Field.Field>
<Field.Label for="name">Device name</Field.Label>
<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>
{/each}
</Field.Field>
@@ -128,7 +128,7 @@
<Field.Field class="col-span-full">
<Field.Label for="title">Title</Field.Label>
<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>
{/each}
</Field.Field>
@@ -136,7 +136,7 @@
<Field.Field>
<Field.Label for="subtitle">Subtitle</Field.Label>
<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>
{/each}
</Field.Field>
@@ -144,7 +144,7 @@
<Field.Field>
<Field.Label for="edition">Edition</Field.Label>
<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>
{/each}
</Field.Field>
@@ -152,7 +152,7 @@
<Field.Field>
<Field.Label for="series">Series</Field.Label>
<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>
{/each}
</Field.Field>
@@ -161,7 +161,7 @@
<Field.Field>
<Field.Label for="series_position">No.</Field.Label>
<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>
{/each}
</Field.Field>
@@ -169,7 +169,7 @@
<Field.Field>
<Field.Label for="language">Language</Field.Label>
<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>
{/each}
</Field.Field>
@@ -185,10 +185,10 @@
placeholder="Add an author"
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)} />
{/each}
{#each updateBookMetadata.fields.authors.issues() ?? [] as issue}
{#each updateBookMetadata.fields.authors.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -201,10 +201,10 @@
placeholder="Add a tag"
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)} />
{/each}
{#each updateBookMetadata.fields.tags.issues() ?? [] as issue}
{#each updateBookMetadata.fields.tags.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -214,7 +214,7 @@
<Field.Field>
<Field.Label for="publisher">Publisher</Field.Label>
<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>
{/each}
</Field.Field>
@@ -223,7 +223,7 @@
<Field.Field>
<Field.Label for="published_date">Published</Field.Label>
<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>
{/each}
</Field.Field>
@@ -231,7 +231,7 @@
<Field.Field>
<Field.Label for="pages">Pages</Field.Label>
<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>
{/each}
</Field.Field>
@@ -240,7 +240,7 @@
<Field.Field class="col-span-full">
<Field.Label for="identifiers">Identifiers</Field.Label>
<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">
<Input bind:value={identifierKeys[idx]} placeholder="ISBN, DOI…" />
<Input bind:value={identifierValues[idx]} placeholder="Value" />
@@ -271,7 +271,7 @@
<Field.Field class="col-span-full">
<Field.Label for="description" class="sr-only">Description</Field.Label>
<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>
{/each}
</Field.Field>
@@ -56,7 +56,7 @@
<Field.Field>
<Field.Label for="name">Library name</Field.Label>
<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>
{/each}
</Field.Field>
@@ -73,7 +73,7 @@
<Field.Field>
<Field.Label for="description">Description</Field.Label>
<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>
{/each}
</Field.Field>
@@ -82,7 +82,7 @@
<Field.Field>
<Field.Label for="root_path">Root Path</Field.Label>
<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>
{/each}
<Field.Description class="text-xs">
@@ -94,7 +94,7 @@
<Field.Field>
<Field.Label for="path_template">Path Template</Field.Label>
<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>
{/each}
<Field.Description class="text-xs">
@@ -21,7 +21,7 @@
<Field.Field>
<Field.Label for="email">Email</Field.Label>
<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>
{/each}
</Field.Field>
@@ -30,7 +30,7 @@
<Field.Field>
<Field.Label for="password">Password</Field.Label>
<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>
{/each}
</Field.Field>
@@ -47,7 +47,7 @@
<Field.Field>
<Field.Label for="email">Email</Field.Label>
<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>
{/each}
</Field.Field>
@@ -56,7 +56,7 @@
<Field.Field>
<Field.Label for="password">Password</Field.Label>
<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>
{/each}
</Field.Field>
@@ -65,7 +65,7 @@
<Field.Field>
<Field.Label for="confirmPassword">Confirm Password</Field.Label>
<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>
{/each}
</Field.Field>
@@ -3,7 +3,7 @@
import * as Sidebar 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 { 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 ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import PlusIcon from '@lucide/svelte/icons/plus';
@@ -53,9 +53,9 @@
}
}
function handleClick(e: any) {
function handleClick(e: Event & { currentTarget: HTMLElement }) {
open = true;
e.target.blur();
e.currentTarget.blur();
}
</script>
@@ -93,7 +93,7 @@
</div>
{:else}
<Command.List class="max-h-[600px]">
{#if searchResult?.items.length > 0}
{#if (searchResult?.items?.length ?? 0) > 0}
<Command.Group heading="Books">
{#each searchResult?.items as book (book.id)}
<Command.Item
@@ -121,7 +121,7 @@
{#if book.authors.length > 0}
<span class="line-clamp-1 w-full text-sm text-muted-foreground">
by
{#each book.authors as author}
{#each book.authors as author (author.id)}
<a
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
libraryId: String(libraryState.activeLibrary!.id)
@@ -132,7 +132,7 @@
</span>
{/if}
<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>
{/each}
</div>
@@ -185,7 +185,6 @@
type="file"
onchange={change}
webkitdirectory={directory}
{directory}
class="hidden"
/>
</label>
@@ -37,7 +37,7 @@
</script>
<Popover.Root bind:open>
<Popover.Trigger asChild>
<Popover.Trigger>
{#snippet child({ props })}
<Button variant="outline" size="icon" class="h-9 w-9" {...props}>
<SelectedIconComponent class="size-4" />
@@ -36,7 +36,6 @@
$effect(() => {
// whenever input value changes reset invalid
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
inputValue;
untrack(() => {
@@ -19,5 +19,4 @@
className
)}
bind:value
{...restProps}
></textarea>
{...restProps}></textarea>
@@ -4,7 +4,6 @@
import type { Book } from '$lib/schema';
import BookThumbnail from './book-thumbnail.svelte';
import { goto } from '$app/navigation';
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
import { getLibraryState } from '$lib/state/library.svelte';
@@ -1,8 +1,10 @@
<script>
let { src, fallback = '/images/default_cover.jpg', class: className = '' } = $props();
async function handleError(e) {
e.target.src = fallback;
/** @param {Event} e */
function handleError(e) {
const img = /** @type {HTMLImageElement} */ (e.currentTarget);
img.src = fallback;
}
</script>
@@ -2,7 +2,6 @@
import { resolve } from '$app/paths';
import * as Table from '$lib/components/ui/table/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 { Badge } from '$lib/components/ui/badge/index';
import { buttonVariants } from '$lib/components/ui/button/index.js';
@@ -2,12 +2,11 @@
import { resolve } from '$app/paths';
import BookImage from './book-image.svelte';
import GeneratedCover from './generated-cover.svelte';
import { Progress } from '$lib/components/ui/progress/index';
import { getLibraryState } from '$lib/state/library.svelte';
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
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 libraryState = getLibraryState();
@@ -78,7 +77,7 @@
<!-- Authors list -->
<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
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
libraryId: String(libraryState.activeLibrary?.id ?? '')
@@ -30,7 +30,7 @@
<Collapsible.Content class="h-full max-h-128 overflow-y-auto">
<Sidebar.GroupContent>
<Sidebar.Menu>
{#each filter.items as item, index (item.id)}
{#each filter.items as item (item.id)}
<Sidebar.MenuItem
onclick={() => bookCollection.toggleFilter(filter.value, item.id.toString())}
>
@@ -25,7 +25,7 @@
</DropdownMenu.Trigger>
<DropdownMenu.Content class="w-40">
<DropdownMenu.Group>
{#each bookCollection.sortOptions as sortProp}
{#each bookCollection.sortOptions as sortProp (sortProp.value)}
<DropdownMenu.Item
onSelect={() => bookCollection.updateSort(sortProp.value)}
class={bookCollection.orderBy === sortProp.value ? 'bg-muted' : ''}
-2
View File
@@ -4,8 +4,6 @@ const emailSchema = z
.email('Please enter a valid email address')
.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 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 { commonQuerySchema, stringArrayCoerce, stringCoerce } from './common';
import { commonQuerySchema, stringArrayCoerce } from './common';
export type Author = components['schemas']['AuthorRead'];
+1 -1
View File
@@ -67,7 +67,7 @@ const identifiersSchema = z
.transform((str, ctx) => {
try {
return JSON.parse(str);
} catch (e) {
} catch {
ctx.addIssue({
code: 'custom',
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 { commonQuerySchema, stringArrayCoerce } from './common';
-2
View File
@@ -1,5 +1,3 @@
import { z } from 'zod';
import type { components } from './openapi/schema';
import { commonQuerySchema, stringArrayCoerce } from './common';
+3 -3
View File
@@ -21,7 +21,7 @@ export class ApiClient {
return this.request(endpoint);
}
async post(endpoint: string, data: any): Promise<Response> {
async post(endpoint: string, data: unknown): Promise<Response> {
return this.request(endpoint, {
method: 'POST',
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, {
method: 'PUT',
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, {
method: 'PATCH',
headers: {
@@ -1,5 +1,5 @@
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 { page } from '$app/state';
import { BookOperationsState } from './bookOperations.svelte';
@@ -105,6 +105,7 @@ export class BookCollectionState {
this.view = next;
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local temporary, not reactive state
const url = new URL(page.url);
url.searchParams.set('view', next);
// Not a route to resolve — this is the current URL with one query param
@@ -138,6 +139,7 @@ export class BookCollectionState {
updateSearchParams() {
// Update URL
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local temporary, not reactive state
const url = new URL(window.location.href);
Object.entries(this.filters).forEach(([filter, values]) => {
@@ -215,6 +217,7 @@ export class BookCollectionState {
// and skips every page in between.
this.currentBookPage = 1;
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local temporary, not reactive state
const urlParams = new URLSearchParams(window.location.search);
// Re-read sort and filter state from URL
@@ -16,7 +16,7 @@ export class BookOperationsState {
// Delete dialog related state
deleteDialogOpen = $state(false);
deleteDialogTitle = $state('Delete books?');
deleteFn = $state((deleteFiles: boolean) => {});
deleteFn = $state((_deleteFiles: boolean) => {});
// Edit dialog related state
editDialogOpen = $state(false);
@@ -131,6 +131,7 @@ export class BookOperationsState {
async downloadBooks(bookIds: string[] | number[], filename?: string) {
// 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);
downloadUrl.searchParams.set('library_id', this.libraryId);
bookIds.forEach((id) => {
@@ -142,6 +143,7 @@ export class BookOperationsState {
async downloadBookFile(bookId: number, fileId: number, filename: string) {
// 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);
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 type { Book, Bookshelf } from '$lib/schema';
import { getContext, setContext } from 'svelte';
@@ -22,7 +22,7 @@ export class BookshelfState {
async fetchBookshelves(libraryId: string) {
try {
let paginatedBookshelves = await listBookshelves({
const paginatedBookshelves = await listBookshelves({
libraries: [libraryId]
});
@@ -34,7 +34,7 @@ export class BookshelfState {
async addBookshelf(name: string, libraryId?: string | number, booksToAdd?: string[] | number[]) {
try {
let bookshelf = await createBookshelf({
const bookshelf = await createBookshelf({
title: name,
library_id: libraryId,
book_ids: booksToAdd
@@ -116,6 +116,7 @@ export class BookshelfState {
if (!bookshelves) return;
// 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>();
books.forEach((book) => {
book.lists.forEach((shelf) => {
@@ -132,7 +133,7 @@ export class BookshelfState {
decrementBy++;
}
});
return { ...shelf, total: shelf.total - decrementBy };
return { ...shelf, total: (shelf.total ?? 0) - decrementBy };
}
return shelf;
});
+1 -1
View File
@@ -1,6 +1,6 @@
import { resolve } from '$app/paths';
import { browser } from '$app/environment';
import { goto, invalidate } from '$app/navigation';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { deleteLibrary } from '$lib/api';
import type { Library } from '$lib/schema';
@@ -1,7 +1,7 @@
<script lang="ts">
import BookDelete from '$lib/components/forms/book-delete.svelte';
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 { setBookSelectionState } from '$lib/state/bookSelection.svelte';
import type { Snippet } from 'svelte';
@@ -28,22 +28,16 @@
import { getBookOperationsState } from '$lib/state/bookOperations.svelte.js';
import { getBookshelfState } from '$lib/state/bookshelf.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 Table from '$lib/components/ui/table/index.js';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import ShelfCreateDialog from '$lib/components/forms/shelf-create-dialog.svelte';
import { untrack } from 'svelte';
let { data } = $props();
// Seeded once, then kept in sync by the effect below — untrack says so
// explicitly rather than capturing the initial value and warning about it.
let book = $state(untrack(() => data.book));
$effect(() => {
book = data.book;
});
// Follows the loaded book, and stays assignable so an edit can update it locally
// until the next navigation supplies a fresh one.
let book = $derived(data.book);
const bookOps = getBookOperationsState();
const libraryState = getLibraryState();
@@ -20,7 +20,13 @@
};
} = $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 bookCollection = setBookCollectionState(bookOps, books, filterData);
+2 -4
View File
@@ -1,9 +1,7 @@
<script lang="ts">
import { listBookshelves } from '$lib/api';
import AppSidebar from '$lib/components/layout/app-sidebar.svelte';
import SiteHeader from '$lib/components/layout/site-header.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 type { Library, PaginatedResponse } from '$lib/schema';
import { setBookOperationsState } from '$lib/state/bookOperations.svelte';
@@ -28,8 +26,8 @@
// init. `untrack` says that explicitly, instead of silently capturing the
// initial value and warning about it.
const libraryState = setLibraryState(untrack(() => data.libraries.items));
const bookshelfState = setBookshelfState();
const bookOps = setBookOperationsState(libraryState.activeLibrary!.id);
setBookshelfState();
setBookOperationsState(libraryState.activeLibrary!.id);
const theme = setThemeState(untrack(() => data.theme));
// 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
long list is clipped rather than reachable. -->
<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
href={resolve(item.routeId)}
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';
export async function load({ parent }) {
const { libraries } = await parent();
export async function load() {
// Immediately redirect to the account settings
redirect(303, `/settings/account`);
}
@@ -21,11 +21,12 @@
import { toast } from 'svelte-sonner';
import { invalidateAll } from '$app/navigation';
import type { Device } from '$lib/schema/device';
import { SvelteSet } from 'svelte/reactivity';
let { data } = $props();
let createDialogOpen = $state(false);
let visibleApiKeys = $state<Set<string>>(new Set());
const visibleApiKeys = new SvelteSet<string>();
let deleteConfirmDevice = $state<Device | null>(null);
let regenerateConfirmDevice = $state<Device | null>(null);
@@ -35,7 +36,6 @@
} else {
visibleApiKeys.add(deviceId);
}
visibleApiKeys = new Set(visibleApiKeys);
}
function maskApiKey(apiKey: string): string {
@@ -130,7 +130,7 @@
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.devices as device}
{#each data.devices as device (device.id)}
{@const isVisible = visibleApiKeys.has(String(device.id))}
<Table.Row class="h-14">
<Table.Cell class="pl-4 font-medium">
@@ -31,7 +31,7 @@
</Table.Row>
</Table.Header>
<Table.Body>
{#each libraryState.libraries as library}
{#each libraryState.libraries as library (library.id)}
<Table.Row class="h-14">
<Table.Cell class="w-16 pl-4 text-center text-lg font-semibold"
>{library.name[0]}</Table.Cell
+2 -2
View File
@@ -1,7 +1,7 @@
// src/routes/api/[...path]/+server.ts
import { BACKEND_API_URL } from '$lib/server/config';
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
async function handleResponse(response: Response) {
@@ -137,7 +137,7 @@ export const DELETE: RequestHandler = async ({ params, locals, fetch, request, u
const headers = prepareRequest(locals, request);
// DELETE may or may not have a body
let options: RequestInit = {
const options: RequestInit = {
method: 'DELETE',
headers
};
-2
View File
@@ -1,9 +1,7 @@
<script lang="ts">
import { replaceState } from '$app/navigation';
import LoginForm from '$lib/components/forms/login-form.svelte';
import SignupForm from '$lib/components/forms/signup-form.svelte';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import { redirect } from '@sveltejs/kit';
let tabValue = $state('login');
</script>