Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
412a73cedf | ||
|
|
5176dfcb77 | ||
|
|
b85ba92380 | ||
|
|
e9fe18266d | ||
|
|
5ec5a4d334 | ||
|
|
0c25f63600 | ||
|
|
64fed8671e | ||
|
|
3a29294f96 | ||
|
|
428168c07a | ||
|
|
fbe8a8bf21 | ||
|
|
157cc60e91 | ||
|
|
930b222b28 | ||
|
|
01cdd95bc7 |
+31
-9
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:`.
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -219,7 +227,7 @@ class TestParseIdentifier:
|
||||
assert parse_identifier("B000FC0PDA", "mobi-asin") == ("asin", "B000FC0PDA")
|
||||
|
||||
def test_an_unrecognised_prefix_is_part_of_the_value(self) -> None:
|
||||
""""http://example.com/book" is not an identifier called "http"."""
|
||||
""" "http://example.com/book" is not an identifier called "http"."""
|
||||
assert parse_identifier("http://www.gutenberg.org/5200") == (
|
||||
"id",
|
||||
"http://www.gutenberg.org/5200",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+27
@@ -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"
|
||||
|
||||
@@ -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
@@ -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**
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+645
-1048
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -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' : ''}
|
||||
|
||||
@@ -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,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'];
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { components } from './openapi/schema';
|
||||
import { commonQuerySchema, stringArrayCoerce } from './common';
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { components } from './openapi/schema';
|
||||
import { commonQuerySchema, stringArrayCoerce } from './common';
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user