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
patrick e898069b03 chore: update to ESLint 10
no-useless-assignment joined the recommended set and does not understand runes,
reading every `$bindable()` prop default as a dead store; off for Svelte files.
Carries @eslint/js, @eslint/compat, globals and @types/node with it.
2026-08-17 16:21:43 -04:00
patrick b33f57d942 chore: update frontend dependencies within their semver ranges
SvelteKit 2.53 to 2.70 renamed the remote form enhance callback's `form` to
`element`, which would have thrown on reset in the signup, device, library and
cover forms.
2026-08-17 16:13:42 -04:00
patrick 54043a97d2 feat: check formatting, linting and tests in CI
Every push runs ruff, prettier, pytest and svelte-check; a tagged release runs the
blocking half again before it publishes an image. eslint and svelte-check report
without failing, since 87 and 30 findings predate the workflow.
2026-08-17 15:17:20 -04:00
patrick 45b03764d2 chore: format the tree and clear ruff's findings
Applies ruff format, ruff check --fix and prettier, so CI can gate on all three.
The surviving unused imports were all re-exports in __init__.py, now covered by a
per-file ignore; the pdf.js viewer and the generated openapi types join the
vendored code that eslint and prettier already skip.
2026-08-17 15:17:14 -04:00
patrick b70ed5cb51 feat: build and publish release images from version tags
Tagging v* builds both images on Gitea Actions and pushes them to the instance
registry, then smoke-tests the published stack. The frontend read its backend
URL through import.meta.env, which Vite resolves at build time, so an image
could only point at whatever the build host had; it now reads it at runtime.
2026-08-17 15:05:18 -04:00
146 changed files with 3492 additions and 3021 deletions
+9
View File
@@ -1,6 +1,15 @@
# Change this secret to something random # Change this secret to something random
CHITAI_TOKEN_SECRET=secret CHITAI_TOKEN_SECRET=secret
# Which release to run. Pin this to a tag (e.g. 0.1.0) for a real deployment so a
# restart cannot silently move you to a newer image.
CHITAI_VERSION=latest
# The URL you reach the app on, exactly as it appears in the browser. Logging in fails
# with a 403 if this does not match, because SvelteKit rejects the form POST as
# cross-origin. Include the port unless it is the scheme default.
CHITAI_ORIGIN=http://localhost:3000
# Setup the path to your initial library (optional) # Setup the path to your initial library (optional)
CHITAI_DEFAULT_LIBRARY_NAME=Books CHITAI_DEFAULT_LIBRARY_NAME=Books
CHITAI_DEFAULT_LIBRARY_PATH="libraries/books" CHITAI_DEFAULT_LIBRARY_PATH="libraries/books"
+102
View File
@@ -0,0 +1,102 @@
name: ci
# Formatting, linting, types and tests on every push and pull request.
#
# 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.
on:
push:
branches: ['**']
pull_request:
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
steps:
- uses: actions/checkout@v4
# astral-sh/setup-uv is not mirrored on gitea.com, unlike the actions used elsewhere
# here, so install uv directly rather than depending on DEFAULT_ACTIONS_URL.
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install dependencies
run: uv sync --locked
- name: Format
run: uv run ruff format --check src/ tests/
- name: Lint
run: uv run ruff check src/ tests/
- name: Tests
run: uv run pytest tests/ -q
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- name: Enable pnpm
run: corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Split out of `pnpm lint` (which is prettier && eslint) so formatting can block
# while eslint does not.
- name: Format
run: pnpm exec prettier --check .
- name: Lint
run: pnpm exec eslint .
continue-on-error: true
- name: Types
run: pnpm check
+142
View File
@@ -0,0 +1,142 @@
name: release
# Builds and publishes the two container images from a version tag.
#
# Tagging v1.2.3 publishes chitai-backend and chitai-frontend as 1.2.3, 1.2, 1 and latest.
# A prerelease tag (v1.2.3-rc.1) publishes only 1.2.3-rc.1 and leaves latest alone, which
# makes -rc tags a safe way to exercise this workflow.
#
# Note that `uses: docker/...` does not mean github.com here the way it would on GitHub. Gitea
# resolves a bare reference against the instance's DEFAULT_ACTIONS_URL, which defaults to
# gitea.com; all five actions below are mirrored there at these tags. If that setting is ever
# pointed somewhere without them, set it to `github` in app.ini rather than editing this file.
#
# Requires a runner with a working Docker daemon (a docker:dind sidecar, or host mode with
# the socket mounted) and, for the smoke job, the compose plugin.
on:
push:
tags:
- 'v*'
env:
REGISTRY: git.jaroszew.ski
jobs:
# The blocking half of ci.yml, run again on the tagged commit so a release cannot publish
# an image whose tests fail. Deliberately duplicated rather than shared: Gitea's support
# for reusable workflows is thinner than GitHub's, and this is a dozen lines.
# Keep in step with ci.yml.
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Backend format, lint and tests
working-directory: backend
run: |
uv sync --locked
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 and types
working-directory: frontend
run: |
corepack enable
pnpm install --frozen-lockfile
pnpm exec prettier --check .
pnpm check
build:
needs: quality
runs-on: ubuntu-latest
strategy:
# The two images are independent artifacts; don't cancel a good build for a bad one.
fail-fast: false
matrix:
include:
- component: backend
context: ./backend
- component: frontend
context: ./frontend
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN || secrets.GITEA_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}-${{ matrix.component }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
labels: |
org.opencontainers.image.title=chitai-${{ matrix.component }}
org.opencontainers.image.source=https://git.jaroszew.ski/${{ github.repository }}
- uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Relies on the runner's cache server. If it is disabled, drop these two lines —
# a cold build of both images is only a few minutes.
cache-from: type=gha
cache-to: type=gha,mode=max
smoke:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN || secrets.GITEA_TOKEN }}
# Boots the stack from the images that were just pushed, rather than rebuilding them.
# This is what catches migrations failing from entrypoint.sh, the frontend being unable
# to reach the backend, and a missing runtime environment variable.
- name: Boot the published images
env:
TAG: ${{ github.ref_name }}
run: |
cp .env.prod-example .env
echo "CHITAI_VERSION=${TAG#v}" >> .env
mkdir -p libraries
docker compose pull backend frontend
docker compose up -d --wait --wait-timeout 180
curl -fsS http://localhost:8000/healthcheck
curl -fsS http://localhost:3000/login > /dev/null
- name: Tear down
if: always()
run: |
docker compose logs --no-color || true
docker compose down -v || true
+8 -1
View File
@@ -70,7 +70,7 @@ Backend (from `backend/`):
```bash ```bash
uv run litestar --app-dir src/chitai/ run --reload # dev server on :8000 uv run litestar --app-dir src/chitai/ run --reload # dev server on :8000
pytest tests/ # needs Docker (pytest-databases) pytest tests/ # needs Docker (pytest-databases)
ruff format src/ ruff format src/ tests/
alchemy --config chitai.database.config.config make-migrations alchemy --config chitai.database.config.config make-migrations
alchemy --config chitai.database.config.config upgrade alchemy --config chitai.database.config.config upgrade
@@ -103,6 +103,13 @@ API docs are served by the running backend at `http://localhost:8000/schema/` (S
manually against a live backend). manually against a live backend).
- **Migrations are mandatory.** The app runs with `create_all=False`, so a model change without a - **Migrations are mandatory.** The app runs with `create_all=False`, so a model change without a
matching Alembic revision will not reach the database. matching Alembic revision will not reach the database.
- **CI gates formatting, linting, 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:`. - **Commit messages** follow `type: summary``feat:`, `fix:`, `refactor:`, `chore:`.
- The three `README.md` files are user-facing. Agent-facing knowledge belongs in the `AGENTS.md` - The three `README.md` files are user-facing. Agent-facing knowledge belongs in the `AGENTS.md`
files. files.
+11 -5
View File
@@ -26,12 +26,18 @@ eBook library management application.
### Installation (Production Deployment) ### Installation (Production Deployment)
1. Clone the repository: `git clone <repository_url>` (Replace `<repository_url>` with the actual URL) 1. Clone the repository: `git clone https://git.jaroszew.ski/patrick/chitai.git`
2. Navigate to the project root: `cd chitai` 2. Navigate to the project root: `cd chitai`
3. Build the Docker images: `docker compose build` 3. Copy the example environment file: `cp .env.prod-example .env`
4. Copy the example environment file and configure: `cp .env.prod-example .env` 4. Edit `.env`. At minimum set `CHITAI_TOKEN_SECRET` to something random, and set
5. Run the Docker containers in detached mode: `docker compose up -d` `CHITAI_ORIGIN` to the URL you will reach the app on — logging in fails with a 403 if it
6. Access the frontend at: `http://localhost:3000/` does not match. Pin `CHITAI_VERSION` to a release tag if you would rather not track `latest`.
5. Pull the released images: `docker compose pull`
6. Run the Docker containers in detached mode: `docker compose up -d`
7. Access the frontend at: `http://localhost:3000/`
To build the images from source instead of pulling them, run `docker compose build` in place of
step 5.
## Development ## Development
+54 -7
View File
@@ -114,18 +114,65 @@ CMD ["litestar", "--app-dir", "chitai", "run", "--host", "0.0.0.0", "--port", "8
`litestar run` is the CLI development runner. Production should invoke uvicorn or granian `litestar run` is the CLI development runner. Production should invoke uvicorn or granian
directly, with a worker count. directly, with a worker count.
### Nothing gates formatting, linting or types ### The delete_files flag is untested in both directions
`ruff format --check src/` reports 27 of 61 files unformatted, and `ruff check src/` finds `backend/tests/integration/test_book.py``test_remove_file_with_delete_files_false_keeps_filesystem_file`
114 errors — 100 of them unused imports, the rest bare `except`, unused variables and and `test_remove_file_with_delete_files_true_removes_filesystem_file`
`== True` comparisons. `ruff check --fix` clears 51 automatically.
There is no `[tool.ruff]` section in `pyproject.toml`, so only ruff's default `E4/E7/E9/F` Both tests capture the file's path and then assert only `response.status_code == 204`.
rules run, and no type checker is configured at all despite `# type: ignore` comments in Neither looks at the disk. So the flag that decides whether removing a file from a book
the tree. Individually these are trivial; collectively they say nothing runs on commit. 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
tree is clean against them. What is still missing is a type checker: nothing runs one despite
`# type: ignore` comments in the tree.
`pyproject.toml` gained a `[tool.ruff.lint.per-file-ignores]` section for `__init__.py`
re-exports, but no rule selection — so only ruff's default `E4/E7/E9/F` rules run. Widening
that set is worthwhile and will surface a fresh batch of findings.
## Frontend ## Frontend
### Book descriptions are rendered as unsanitized HTML
`frontend/src/lib/components/ui/collapsible-text/collapsible-text.svelte` — lines 42 and 45
The component renders `{@html text}`, and its only caller is the book detail page:
`<CollapsibleText text={book.description} maxLength={500} />`. So whatever is in
`Book.description` reaches the DOM as markup.
The Calibre importer is fine — `services/calibre.py:401` passes comments through
`strip_html`, because Calibre stores HTML there. But `strip_html` is used **nowhere else in
the backend**, and `EpubExtractor._extract_description` returns
`epub.get_metadata("DC", "description")[0][0]` verbatim. EPUB `dc:description` routinely
carries markup, so an uploaded book with `<img src=x onerror=…>` in that field executes
script on the book page, with the session cookie in scope. Metadata edited through the UI
is stored unfiltered too.
This is the same class as the scripted-EPUB item below — untrusted file content reaching an
origin that holds a session — by a different route, and it does not need `allow-scripts` to
work.
Fix: sanitize at ingest, next to where Calibre already does. Reuse `strip_html` in
`_extract_description` if descriptions should be plain text, or run an allowlist sanitizer if
the formatting is worth keeping. Either way the stored rows need backfilling through the same
helper, since the validators only fire on write. Dropping `{@html}` to `{text}` in the
component fixes the display side but leaves the payload in the database.
These are the only two findings `pnpm exec eslint .` still reports; CI's eslint step stops
being `continue-on-error` once they are gone.
### Scripted EPUBs run against the app origin ### Scripted EPUBs run against the app origin
**This is a regression from the foliate-js migration, not a pre-existing gap.** **This is a regression from the foliate-js migration, not a pre-existing gap.**
+6
View File
@@ -36,8 +36,14 @@ dev = [
"pytest>=8.4.2", "pytest>=8.4.2",
"pytest-asyncio>=1.2.0", "pytest-asyncio>=1.2.0",
"pytest-databases[postgres]>=0.15.0", "pytest-databases[postgres]>=0.15.0",
"ruff==0.15.14",
] ]
[tool.ruff.lint.per-file-ignores]
# The package __init__.py files exist to re-export their modules' public names, so every
# import in them is "unused" as far as F401 is concerned.
"__init__.py" = ["F401"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
testpaths = ["tests"] testpaths = ["tests"]
+2 -1
View File
@@ -4,7 +4,8 @@ pkgs.mkShell {
buildInputs = with pkgs; [ buildInputs = with pkgs; [
# Python development environment for Chitai # Python development environment for Chitai
python313Packages.greenlet python313Packages.greenlet
python313Packages.ruff # ruff is a dev dependency in pyproject.toml, not a shell package: CI has no nix, so a
# nix-only formatter is one CI cannot run, and two copies could disagree on formatting.
uv uv
# postgres database # postgres database
+5 -1
View File
@@ -72,6 +72,7 @@ oauth2_auth = OAuth2PasswordBearerAuth[User](
watcher_task: asyncio.Task watcher_task: asyncio.Task
@asynccontextmanager @asynccontextmanager
async def setup_db_connection(app: Litestar) -> AsyncGenerator[None, None]: async def setup_db_connection(app: Litestar) -> AsyncGenerator[None, None]:
# Setup databse # Setup databse
@@ -107,7 +108,9 @@ async def setup_directory_watcher(app: Litestar) -> AsyncGenerator[None, None]:
book_service = BookService(session=db_session) book_service = BookService(session=db_session)
library_service = LibraryService(session=db_session) library_service = LibraryService(session=db_session)
file_watcher = ConsumeDirectoryWatcher(settings.consume_path, library_service, book_service) file_watcher = ConsumeDirectoryWatcher(
settings.consume_path, library_service, book_service
)
watcher_task = asyncio.create_task(file_watcher.init_watcher()) watcher_task = asyncio.create_task(file_watcher.init_watcher())
try: try:
@@ -115,6 +118,7 @@ async def setup_directory_watcher(app: Litestar) -> AsyncGenerator[None, None]:
finally: finally:
watcher_task.cancel() watcher_task.cancel()
def create_app() -> Litestar: def create_app() -> Litestar:
return Litestar( return Litestar(
route_handlers=[ route_handlers=[
+1 -1
View File
@@ -1,7 +1,7 @@
# src/chitai/controllers/access.py # src/chitai/controllers/access.py
# Standard library # Standard library
from typing import Annotated, Any from typing import Annotated
import logging import logging
# Third-party libraries # Third-party libraries
+1 -2
View File
@@ -4,9 +4,8 @@
from typing import Annotated from typing import Annotated
# Third-party libraries # Third-party libraries
from litestar import Controller, post, get, patch, delete from litestar import Controller, get
from litestar.params import Dependency from litestar.params import Dependency
from litestar.exceptions import HTTPException
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
from advanced_alchemy.service.pagination import OffsetPagination from advanced_alchemy.service.pagination import OffsetPagination
from advanced_alchemy.service import FilterTypeT from advanced_alchemy.service import FilterTypeT
+26 -22
View File
@@ -8,47 +8,51 @@ from litestar import Controller, post, get, delete
from litestar.di import Provide from litestar.di import Provide
from chitai.services import dependencies as deps from chitai.services import dependencies as deps
class DeviceController(Controller):
""" Controller for managing KOReader devices."""
dependencies = { class DeviceController(Controller):
"device_service": Provide(deps.provide_kosync_device_service) """Controller for managing KOReader devices."""
}
dependencies = {"device_service": Provide(deps.provide_kosync_device_service)}
path = "/devices" path = "/devices"
@get() @get()
async def get_devices(self, device_service: KosyncDeviceService, current_user: User) -> OffsetPagination[KosyncDeviceRead]: async def get_devices(
""" Return a list of all the user's devices.""" self, device_service: KosyncDeviceService, current_user: User
devices = await device_service.list( ) -> OffsetPagination[KosyncDeviceRead]:
KosyncDevice.user_id == current_user.id """Return a list of all the user's devices."""
) devices = await device_service.list(KosyncDevice.user_id == current_user.id)
return device_service.to_schema(devices, schema_type=KosyncDeviceRead) return device_service.to_schema(devices, schema_type=KosyncDeviceRead)
@post() @post()
async def create_device(self, data: KosyncDeviceCreate, device_service: KosyncDeviceService, current_user: User) -> KosyncDeviceRead: async def create_device(
device = await device_service.create({ self,
'name': data.name, data: KosyncDeviceCreate,
'user_id': current_user.id device_service: KosyncDeviceService,
}) current_user: User,
) -> KosyncDeviceRead:
device = await device_service.create(
{"name": data.name, "user_id": current_user.id}
)
return device_service.to_schema(device, schema_type=KosyncDeviceRead) return device_service.to_schema(device, schema_type=KosyncDeviceRead)
@delete("/{device_id:int}") @delete("/{device_id:int}")
async def delete_device(self, device_id: int, device_service: KosyncDeviceService, current_user: User) -> None: async def delete_device(
self, device_id: int, device_service: KosyncDeviceService, current_user: User
) -> None:
# Ensure the device exists and is owned by the user # Ensure the device exists and is owned by the user
device = await device_service.get_one( device = await device_service.get_one(
KosyncDevice.id == device_id, KosyncDevice.id == device_id, KosyncDevice.user_id == current_user.id
KosyncDevice.user_id == current_user.id
) )
await device_service.delete(device.id) await device_service.delete(device.id)
@get("/{device_id:int}/regenerate") @get("/{device_id:int}/regenerate")
async def regenerate_device_api_key(self, device_id: int, device_service: KosyncDeviceService, current_user: User) -> KosyncDeviceRead: async def regenerate_device_api_key(
self, device_id: int, device_service: KosyncDeviceService, current_user: User
) -> KosyncDeviceRead:
# Ensure the device exists and is owned by the user # Ensure the device exists and is owned by the user
device = await device_service.get_one( device = await device_service.get_one(
KosyncDevice.id == device_id, KosyncDevice.id == device_id, KosyncDevice.user_id == current_user.id
KosyncDevice.user_id == current_user.id
) )
updated_device = await device_service.regenerate_api_key(device.id) updated_device = await device_service.regenerate_api_key(device.id)
return device_service.to_schema(updated_device, schema_type=KosyncDeviceRead) return device_service.to_schema(updated_device, schema_type=KosyncDeviceRead)
@@ -58,10 +58,14 @@ class KosyncController(Controller):
user: m.User, user: m.User,
) -> KosyncProgressRead: ) -> KosyncProgressRead:
"""Return the Kosync progress record associated with the given document.""" """Return the Kosync progress record associated with the given document."""
progress = await kosync_progress_service.get_by_document_hash(user.id, document_id) progress = await kosync_progress_service.get_by_document_hash(
user.id, document_id
)
if not progress: if not progress:
raise HTTPException(status_code=404, detail="No progress found for document") raise HTTPException(
status_code=404, detail="No progress found for document"
)
return KosyncProgressRead( return KosyncProgressRead(
document=progress.document, document=progress.document,
@@ -85,6 +89,3 @@ class KosyncController(Controller):
detail="User accounts must be created via the main application", detail="User accounts must be created via the main application",
status_code=HTTP_403_FORBIDDEN, status_code=HTTP_403_FORBIDDEN,
) )
+4 -2
View File
@@ -10,7 +10,7 @@ from typing import Annotated
# Third-party libraries # Third-party libraries
import aiofiles import aiofiles
from aiofiles import os as aios from aiofiles import os as aios
from litestar import Controller, post, get, patch, delete from litestar import Controller, post, get, delete
from litestar.enums import RequestEncodingType from litestar.enums import RequestEncodingType
from litestar.params import Body, Dependency from litestar.params import Body, Dependency
from litestar.exceptions import HTTPException from litestar.exceptions import HTTPException
@@ -89,7 +89,9 @@ class LibraryController(Controller):
Injected Dependencies: Injected Dependencies:
library_service: The service used to query and return library data. library_service: The service used to query and return library data.
""" """
results, total = await library_service.list_and_count(*filters, load=[m.Library.books]) results, total = await library_service.list_and_count(
*filters, load=[m.Library.books]
)
return library_service.to_schema( return library_service.to_schema(
results, total, filters, schema_type=LibraryRead results, total, filters, schema_type=LibraryRead
) )
+67 -53
View File
@@ -1,4 +1,3 @@
from chitai.services import dependencies as deps from chitai.services import dependencies as deps
from chitai.database import models as m from chitai.database import models as m
from chitai.services.author import AuthorService from chitai.services.author import AuthorService
@@ -6,7 +5,15 @@ from chitai.services.filters.author import AuthorLibraryFilter
from chitai.services.filters.publisher import PublisherLibraryFilter from chitai.services.filters.publisher import PublisherLibraryFilter
from chitai.services.filters.tags import TagLibraryFilter from chitai.services.filters.tags import TagLibraryFilter
from chitai.services.opds.models import Entry, Link, LinkTypes, LinkRelations from chitai.services.opds.models import Entry, Link, LinkTypes, LinkRelations
from chitai.services.opds.opds import create_acquisition_feed, create_navigation_feed, create_library_navigation_feed, create_collection_navigation_feed, create_pagination_links, create_search_link, get_opensearch_document from chitai.services.opds.opds import (
create_acquisition_feed,
create_navigation_feed,
create_library_navigation_feed,
create_collection_navigation_feed,
create_pagination_links,
create_search_link,
get_opensearch_document,
)
from chitai.services import BookService, ShelfService, LibraryService from chitai.services import BookService, ShelfService, LibraryService
from chitai.services.publisher import PublisherService from chitai.services.publisher import PublisherService
from chitai.services.tag import TagService from chitai.services.tag import TagService
@@ -22,11 +29,10 @@ from litestar.params import Dependency
from advanced_alchemy.service import FilterTypeT from advanced_alchemy.service import FilterTypeT
class OpdsController(Controller): class OpdsController(Controller):
""" Controller for managing OPDS endpoints """ """Controller for managing OPDS endpoints"""
middleware=[basic_auth_mw] middleware = [basic_auth_mw]
dependencies = { dependencies = {
"user": Provide(deps.provide_user_via_basic_auth), "user": Provide(deps.provide_user_via_basic_auth),
@@ -76,10 +82,11 @@ class OpdsController(Controller):
title=lib.name, title=lib.name,
href=f"/opds/library/{lib.id}", href=f"/opds/library/{lib.id}",
rel=LinkRelations.SUBSECTION, rel=LinkRelations.SUBSECTION,
type=LinkTypes.NAVIGATION type=LinkTypes.NAVIGATION,
) )
] ],
) for lib in libraries )
for lib in libraries
] ]
feed = create_navigation_feed( feed = create_navigation_feed(
@@ -94,13 +101,10 @@ class OpdsController(Controller):
title="Search books", title="Search books",
) )
], ],
entries=entries entries=entries,
) )
return Response( return Response(feed, media_type="application/xml")
feed,
media_type="application/xml"
)
@get("/acquisition") @get("/acquisition")
async def get_acquisition_feed( async def get_acquisition_feed(
@@ -109,8 +113,10 @@ class OpdsController(Controller):
feed_id: str, feed_id: str,
feed_title: str, feed_title: str,
books_service: BookService, books_service: BookService,
book_filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [], book_filters: Annotated[
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [] list[FilterTypeT], Dependency(skip_validation=True)
] = [],
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [],
) -> Response: ) -> Response:
all_filters = [*filters, *book_filters] all_filters = [*filters, *book_filters]
@@ -121,20 +127,22 @@ class OpdsController(Controller):
links = [] links = []
# Create pagination links if it is a paginated feed # Create pagination links if it is a paginated feed
if request.query_params.get('paginated'): if request.query_params.get("paginated"):
pagination = create_pagination_links( pagination = create_pagination_links(
request=request, request=request,
total=total, total=total,
limit=limit, limit=limit,
offset=offset, offset=offset,
feed_title=feed_title, feed_title=feed_title,
link_type=LinkTypes.ACQUISITION link_type=LinkTypes.ACQUISITION,
) )
links.extend([link for link in [pagination.next_link, pagination.prev_link] if link]) links.extend(
[link for link in [pagination.next_link, pagination.prev_link] if link]
)
# Add search link if this is a searchable feed # Add search link if this is a searchable feed
if request.query_params.get('search'): if request.query_params.get("search"):
links.append(create_search_link(request)) links.append(create_search_link(request))
# Create self URL # Create self URL
@@ -150,15 +158,14 @@ class OpdsController(Controller):
return Response(feed, media_type="application/xml") return Response(feed, media_type="application/xml")
@get("/opensearch") @get("/opensearch")
async def opensearch(self, user: m.User, request: Request) -> Response: async def opensearch(self, user: m.User, request: Request) -> Response:
return Response( return Response(
get_opensearch_document( get_opensearch_document(
base_url=f'/opds/search?{urlencode(list(request.query_params.items()), doseq=True)}&' base_url=f"/opds/search?{urlencode(list(request.query_params.items()), doseq=True)}&"
), ),
media_type="application/xml" media_type="application/xml",
) )
@get("/library/{library_id:int}") @get("/library/{library_id:int}")
@@ -168,7 +175,6 @@ class OpdsController(Controller):
return Response(feed, media_type="application/xml") return Response(feed, media_type="application/xml")
@get("/library/{library_id:int}/{collection_type:str}") @get("/library/{library_id:int}/{collection_type:str}")
async def get_library_collection_feed( async def get_library_collection_feed(
self, self,
@@ -180,34 +186,46 @@ class OpdsController(Controller):
tag_service: TagService, tag_service: TagService,
publisher_service: PublisherService, publisher_service: PublisherService,
request: Request, request: Request,
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [] filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [],
) -> Response: ) -> Response:
service_map = { service_map = {
'shelves': (shelf_service, lambda: shelf_service.list_and_count( "shelves": (
shelf_service,
lambda: shelf_service.list_and_count(
*filters, *filters,
CollectionFilter("library_id", values=[library.id]), CollectionFilter("library_id", values=[library.id]),
OrderBy("name", "asc"), OrderBy("name", "asc"),
m.BookList.user_id == user.id m.BookList.user_id == user.id,
)), ),
'tags': (tag_service, lambda: tag_service.list_and_count( ),
"tags": (
tag_service,
lambda: tag_service.list_and_count(
*filters, *filters,
TagLibraryFilter(libraries=[library.id]), TagLibraryFilter(libraries=[library.id]),
OrderBy("name", "asc"), OrderBy("name", "asc"),
uniquify=True, uniquify=True,
)), ),
'authors': (author_service, lambda: author_service.list_and_count( ),
"authors": (
author_service,
lambda: author_service.list_and_count(
*filters, *filters,
AuthorLibraryFilter(libraries=[library.id]), AuthorLibraryFilter(libraries=[library.id]),
OrderBy("name", "asc"), OrderBy("name", "asc"),
uniquify=True uniquify=True,
)), ),
'publishers': (publisher_service, lambda: publisher_service.list_and_count( ),
"publishers": (
publisher_service,
lambda: publisher_service.list_and_count(
*filters, *filters,
PublisherLibraryFilter(libraries=[library.id]), PublisherLibraryFilter(libraries=[library.id]),
OrderBy("name", "asc"), OrderBy("name", "asc"),
uniquify=True uniquify=True,
)) ),
),
} }
if collection_type not in service_map: if collection_type not in service_map:
@@ -220,38 +238,37 @@ class OpdsController(Controller):
# Create pagination links if it is a paginated feed # Create pagination links if it is a paginated feed
limit, offset = extract_limit_offset(filters) limit, offset = extract_limit_offset(filters)
if request.query_params.get('paginated'): if request.query_params.get("paginated"):
pagination = create_pagination_links( pagination = create_pagination_links(
request=request, request=request,
total=total, total=total,
limit=limit, limit=limit,
offset=offset, offset=offset,
feed_title=collection_type, feed_title=collection_type,
link_type=LinkTypes.ACQUISITION link_type=LinkTypes.ACQUISITION,
) )
links.extend([link for link in [pagination.next_link, pagination.prev_link] if link]) links.extend(
[link for link in [pagination.next_link, pagination.prev_link] if link]
)
feed = create_collection_navigation_feed(library, collection_type, items, links) feed = create_collection_navigation_feed(library, collection_type, items, links)
return Response(feed, media_type="application/xml") return Response(feed, media_type="application/xml")
@get("/search") @get("/search")
async def search_books( async def search_books(
self, books_service: BookService, self,
books_service: BookService,
request: Request, request: Request,
book_filters: Annotated[ book_filters: Annotated[
list[FilterTypeT], Dependency(skip_validation=True) list[FilterTypeT], Dependency(skip_validation=True)
] = [], ] = [],
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [] filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [],
) -> Response: ) -> Response:
filters = [*filters, *book_filters] filters = [*filters, *book_filters]
books, total = await books_service.list_and_count( books, total = await books_service.list_and_count(*filters)
*filters
)
limit, offset = extract_limit_offset(filters) limit, offset = extract_limit_offset(filters)
@@ -262,17 +279,17 @@ class OpdsController(Controller):
limit=limit, limit=limit,
offset=offset, offset=offset,
feed_title="Search Results", feed_title="Search Results",
link_type=LinkTypes.ACQUISITION link_type=LinkTypes.ACQUISITION,
) )
links = [link for link in [pagination.next_link, pagination.prev_link] if link] links = [link for link in [pagination.next_link, pagination.prev_link] if link]
catalog_xml = create_acquisition_feed( catalog_xml = create_acquisition_feed(
id=f"/opds/search?q=q", id="/opds/search?q=q",
title="Search results", title="Search results",
url=f"/opds/search?q=q", url="/opds/search?q=q",
books=books, books=books,
links=links links=links,
) )
return Response(catalog_xml, media_type="application/xml") return Response(catalog_xml, media_type="application/xml")
@@ -287,10 +304,7 @@ class OpdsController(Controller):
def extract_limit_offset(filters: list[FilterTypeT]) -> tuple[int, int]: def extract_limit_offset(filters: list[FilterTypeT]) -> tuple[int, int]:
"""Extract page size and offset from filters""" """Extract page size and offset from filters"""
limit_offset_filter = next( limit_offset_filter = next((f for f in filters if isinstance(f, LimitOffset)), None)
(f for f in filters if isinstance(f, LimitOffset)),
None
)
if limit_offset_filter: if limit_offset_filter:
return limit_offset_filter.limit, limit_offset_filter.offset return limit_offset_filter.limit, limit_offset_filter.offset
+1 -1
View File
@@ -4,7 +4,7 @@
from typing import Annotated from typing import Annotated
# Third-party libraries # Third-party libraries
from litestar import Controller, post, get, patch, delete from litestar import Controller, get
from litestar.params import Dependency from litestar.params import Dependency
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
from advanced_alchemy.service.pagination import OffsetPagination from advanced_alchemy.service.pagination import OffsetPagination
+3 -1
View File
@@ -70,7 +70,9 @@ class BookshelfController(Controller):
filters.append(CollectionFilter("library_id", values=libraries)) filters.append(CollectionFilter("library_id", values=libraries))
filters.append(m.BookList.user_id == current_user.id) filters.append(m.BookList.user_id == current_user.id)
results, total = await shelf_service.list_and_count(*filters, load=[selectinload(m.BookList.book_links)]) results, total = await shelf_service.list_and_count(
*filters, load=[selectinload(m.BookList.book_links)]
)
return shelf_service.to_schema(results, total, filters, schema_type=ShelfRead) return shelf_service.to_schema(results, total, filters, schema_type=ShelfRead)
@post() @post()
+1 -1
View File
@@ -4,7 +4,7 @@
from typing import Annotated from typing import Annotated
# Third-party libraries # Third-party libraries
from litestar import Controller, post, get, patch, delete from litestar import Controller, get
from litestar.params import Dependency from litestar.params import Dependency
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
from advanced_alchemy.service.pagination import OffsetPagination from advanced_alchemy.service.pagination import OffsetPagination
@@ -1,7 +1,6 @@
from typing import Optional from typing import Optional
from sqlalchemy import ForeignKey from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import relationship
from advanced_alchemy.base import BigIntAuditBase from advanced_alchemy.base import BigIntAuditBase
@@ -1,12 +1,10 @@
from collections.abc import Hashable from collections.abc import Hashable
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import ColumnElement, ForeignKey from sqlalchemy import ColumnElement
from advanced_alchemy.base import BigIntAuditBase from advanced_alchemy.base import BigIntAuditBase
from advanced_alchemy.mixins import UniqueMixin from advanced_alchemy.mixins import UniqueMixin
from .book import Book
class BookSeries(BigIntAuditBase, UniqueMixin): class BookSeries(BigIntAuditBase, UniqueMixin):
__tablename__ = "book_series" __tablename__ = "book_series"
@@ -1,9 +1,10 @@
from sqlalchemy import ColumnElement, ForeignKey from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column from sqlalchemy.orm import mapped_column
from advanced_alchemy.base import BigIntAuditBase from advanced_alchemy.base import BigIntAuditBase
class KosyncDevice(BigIntAuditBase): class KosyncDevice(BigIntAuditBase):
__tablename__ = "devices" __tablename__ = "devices"
@@ -1,6 +1,4 @@
from litestar import Request, Response, MediaType from litestar import Request, Response, MediaType
from litestar.exceptions import HTTPException
from litestar.status_codes import HTTP_404_NOT_FOUND
from advanced_alchemy.exceptions import NotFoundError from advanced_alchemy.exceptions import NotFoundError
+11 -6
View File
@@ -3,7 +3,7 @@ from chitai.services.user import UserService
from litestar.middleware import ( from litestar.middleware import (
AbstractAuthenticationMiddleware, AbstractAuthenticationMiddleware,
AuthenticationResult, AuthenticationResult,
DefineMiddleware DefineMiddleware,
) )
from litestar.connection import ASGIConnection from litestar.connection import ASGIConnection
from litestar.exceptions import NotAuthorizedException, PermissionDeniedException from litestar.exceptions import NotAuthorizedException, PermissionDeniedException
@@ -11,19 +11,24 @@ from chitai.config import settings
class BasicAuthenticationMiddleware(AbstractAuthenticationMiddleware): class BasicAuthenticationMiddleware(AbstractAuthenticationMiddleware):
async def authenticate_request(self, connection: ASGIConnection) -> AuthenticationResult: async def authenticate_request(
"""Given a request, parse the header for Base64 encoded basic auth credentials. """ self, connection: ASGIConnection
) -> AuthenticationResult:
"""Given a request, parse the header for Base64 encoded basic auth credentials."""
# retrieve the auth header # retrieve the auth header
auth_header = connection.headers.get("Authorization", None) auth_header = connection.headers.get("Authorization", None)
if not auth_header: if not auth_header:
raise NotAuthorizedException() raise NotAuthorizedException()
username, password = b64decode(auth_header.split("Basic ")[1]).decode().split(":") username, password = (
b64decode(auth_header.split("Basic ")[1]).decode().split(":")
)
try: try:
db_session = settings.alchemy_config.provide_session(connection.app.state, connection.scope) db_session = settings.alchemy_config.provide_session(
connection.app.state, connection.scope
)
user_service = UserService(db_session) user_service = UserService(db_session)
user = await user_service.authenticate(username, password) user = await user_service.authenticate(username, password)
return AuthenticationResult(user=user, auth=None) return AuthenticationResult(user=user, auth=None)
+8 -4
View File
@@ -3,7 +3,7 @@ from chitai.services.kosync_device import KosyncDeviceService
from litestar.middleware import ( from litestar.middleware import (
AbstractAuthenticationMiddleware, AbstractAuthenticationMiddleware,
AuthenticationResult, AuthenticationResult,
DefineMiddleware DefineMiddleware,
) )
from litestar.connection import ASGIConnection from litestar.connection import ASGIConnection
from litestar.exceptions import NotAuthorizedException, PermissionDeniedException from litestar.exceptions import NotAuthorizedException, PermissionDeniedException
@@ -11,8 +11,10 @@ from chitai.config import settings
class KosyncAuthenticationMiddleware(AbstractAuthenticationMiddleware): class KosyncAuthenticationMiddleware(AbstractAuthenticationMiddleware):
async def authenticate_request(self, connection: ASGIConnection) -> AuthenticationResult: async def authenticate_request(
"""Given a request, parse the header for Base64 encoded basic auth credentials. """ self, connection: ASGIConnection
) -> AuthenticationResult:
"""Given a request, parse the header for Base64 encoded basic auth credentials."""
# retrieve the auth header # retrieve the auth header
api_key = connection.headers.get("X-AUTH-USER", None) api_key = connection.headers.get("X-AUTH-USER", None)
@@ -20,7 +22,9 @@ class KosyncAuthenticationMiddleware(AbstractAuthenticationMiddleware):
raise NotAuthorizedException() raise NotAuthorizedException()
try: try:
db_session = settings.alchemy_config.provide_session(connection.app.state, connection.scope) db_session = settings.alchemy_config.provide_session(
connection.app.state, connection.scope
)
user_service = UserService(db_session) user_service = UserService(db_session)
device_service = KosyncDeviceService(db_session) device_service = KosyncDeviceService(db_session)
-2
View File
@@ -273,5 +273,3 @@ class BookProgressCreate(BaseModel):
completed: bool | None = None completed: bool | None = None
device_type: str | None = None device_type: str | None = None
device_id: str | None = None device_id: str | None = None
+2 -1
View File
@@ -1,9 +1,9 @@
from pathlib import Path
from typing import Annotated from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field, SkipValidation, computed_field from pydantic import BaseModel, ConfigDict, Field, SkipValidation, computed_field
from litestar.datastructures import UploadFile from litestar.datastructures import UploadFile
from advanced_alchemy.utils.text import slugify from advanced_alchemy.utils.text import slugify
class LibraryCreate(BaseModel): class LibraryCreate(BaseModel):
name: Annotated[str, Field(min_length=1)] name: Annotated[str, Field(min_length=1)]
root_path: str root_path: str
@@ -17,6 +17,7 @@ class LibraryCreate(BaseModel):
def slug(self) -> str: def slug(self) -> str:
return slugify(self.name) return slugify(self.name)
class LibraryRead(BaseModel): class LibraryRead(BaseModel):
id: int id: int
name: str name: str
+1 -1
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, Field, field_validator
class ShelfRead(BaseModel): class ShelfRead(BaseModel):
+67 -33
View File
@@ -19,7 +19,7 @@ from advanced_alchemy.service import (
SQLAlchemyAsyncRepositoryService, SQLAlchemyAsyncRepositoryService,
ModelDictT, ModelDictT,
is_dict, is_dict,
schema_dump schema_dump,
) )
from advanced_alchemy.repository import SQLAlchemyAsyncRepository from advanced_alchemy.repository import SQLAlchemyAsyncRepository
from advanced_alchemy.filters import CollectionFilter from advanced_alchemy.filters import CollectionFilter
@@ -349,7 +349,9 @@ def _series_position(data: Any) -> tuple[str, str | None]:
return normalize_title(series), (position or "").strip() or None return normalize_title(series), (position or "").strip() or None
def _is_different_volume(left: tuple[str, str | None], right: tuple[str, str | None]) -> bool: def _is_different_volume(
left: tuple[str, str | None], right: tuple[str, str | None]
) -> bool:
""" """
Whether two books are numbered entries of one series, and not the same entry. Whether two books are numbered entries of one series, and not the same entry.
@@ -464,8 +466,6 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
repository_type = Repo repository_type = Repo
async def create_book( async def create_book(
self, self,
data: ModelDictT[Book], data: ModelDictT[Book],
@@ -647,7 +647,10 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
return [] return []
statement = ( statement = (
select(Book).where(or_(*conditions)).options(*self._MATCH_LOADS).order_by(Book.id) select(Book)
.where(or_(*conditions))
.options(*self._MATCH_LOADS)
.order_by(Book.id)
) )
if exclude_book_id is not None: if exclude_book_id is not None:
@@ -724,7 +727,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
ValueError: If fewer than two distinct books were named, one of them does ValueError: If fewer than two distinct books were named, one of them does
not exist, or they do not all belong to one library. not exist, or they do not all belong to one library.
""" """
merged_ids = [book_id for book_id in dict.fromkeys(merged_ids) if book_id != survivor_id] merged_ids = [
book_id for book_id in dict.fromkeys(merged_ids) if book_id != survivor_id
]
if not merged_ids: if not merged_ids:
raise ValueError("A merge needs at least two different books") raise ValueError("A merge needs at least two different books")
@@ -848,12 +853,16 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# Reading progress is per user, and the furthest one is the true answer for a # Reading progress is per user, and the furthest one is the true answer for a
# reader who has been through the EPUB and not the PDF. # reader who has been through the EPUB and not the PDF.
rows = ( rows = (
(
await session.execute( await session.execute(
select(BookProgress) select(BookProgress)
.where(BookProgress.book_id.in_([survivor_id, *merged_ids])) .where(BookProgress.book_id.in_([survivor_id, *merged_ids]))
.order_by(BookProgress.percentage.desc()) .order_by(BookProgress.percentage.desc())
) )
).scalars().all() )
.scalars()
.all()
)
furthest: dict[int, BookProgress] = {} furthest: dict[int, BookProgress] = {}
for progress in rows: for progress in rows:
@@ -902,14 +911,19 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# and only one of them however many books offered it. # and only one of them however many books offered it.
held_names = select(Identifier.name).where(Identifier.book_id == survivor_id) held_names = select(Identifier.name).where(Identifier.book_id == survivor_id)
incoming = ( incoming = (
(
await session.execute( await session.execute(
select(Identifier) select(Identifier)
.where( .where(
Identifier.book_id.in_(merged_ids), Identifier.name.notin_(held_names) Identifier.book_id.in_(merged_ids),
Identifier.name.notin_(held_names),
) )
.order_by(Identifier.book_id, Identifier.id) .order_by(Identifier.book_id, Identifier.id)
) )
).scalars().all() )
.scalars()
.all()
)
taken: set[str] = set() taken: set[str] = set()
for identifier in incoming: for identifier in incoming:
@@ -932,6 +946,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
merged = set(merged_ids) merged = set(merged_ids)
rows = ( rows = (
(
await session.execute( await session.execute(
select(DuplicateDismissal).where( select(DuplicateDismissal).where(
or_( or_(
@@ -940,7 +955,10 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
) )
) )
) )
).scalars().all() )
.scalars()
.all()
)
existing = await self._dismissed_pairs() existing = await self._dismissed_pairs()
doomed: list[int] = [] doomed: list[int] = []
@@ -1025,7 +1043,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# copies meet as long as they agree on any one of them. # copies meet as long as they agree on any one of them.
for title in titles: for title in titles:
for author in authors: for author in authors:
buckets[(MATCHED_ON_TITLE_AUTHOR, f"{title}\x00{author}")].append(book_id) buckets[(MATCHED_ON_TITLE_AUTHOR, f"{title}\x00{author}")].append(
book_id
)
dismissed = await self._dismissed_pairs() dismissed = await self._dismissed_pairs()
series = {book_id: _series_position(book) for book_id, book in books.items()} series = {book_id: _series_position(book) for book_id, book in books.items()}
@@ -1071,8 +1091,14 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
raise ValueError("A book cannot be dismissed against itself") raise ValueError("A book cannot be dismissed against itself")
found = ( found = (
await self.repository.session.execute(select(Book.id).where(Book.id.in_(pair))) (
).scalars().all() await self.repository.session.execute(
select(Book.id).where(Book.id.in_(pair))
)
)
.scalars()
.all()
)
if len(set(found)) != 2: if len(set(found)) != 2:
raise ValueError("No such book") raise ValueError("No such book")
@@ -1305,7 +1331,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
**kwargs, **kwargs,
) )
result.books.append(book) result.books.append(book)
await self._record_possible_duplicates(result.possible_duplicates, book, library) await self._record_possible_duplicates(
result.possible_duplicates, book, library
)
return result return result
@@ -1344,7 +1372,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
consume_path: Path, consume_path: Path,
library: Library, library: Library,
allow_duplicates: bool = False, allow_duplicates: bool = False,
**kwargs **kwargs,
) -> ImportResult: ) -> ImportResult:
""" """
Import files that are already on disk, from the consume directory. Import files that are already on disk, from the consume directory.
@@ -1368,7 +1396,6 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
result = ImportResult() result = ImportResult()
file_groups: dict[Path, list[Path]] = defaultdict(list) file_groups: dict[Path, list[Path]] = defaultdict(list)
for file_path in file_paths: for file_path in file_paths:
rel_path = file_path.relative_to(consume_path) rel_path = file_path.relative_to(consume_path)
parent_rel = rel_path.parent parent_rel = rel_path.parent
@@ -1383,7 +1410,6 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# Add to appropriate group # Add to appropriate group
file_groups[group_key].append(file_path) file_groups[group_key].append(file_path)
# For each grouping # For each grouping
for group, files in file_groups.items(): for group, files in file_groups.items():
# Fingerprinted before anything moves, since the paths are about to change. # Fingerprinted before anything moves, since the paths are about to change.
@@ -1405,7 +1431,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
cleanup_empty_parent_directories(consume_path / group, consume_path) cleanup_empty_parent_directories(consume_path / group, consume_path)
continue continue
data: dict[str, Any] = {'files': accepted} data: dict[str, Any] = {"files": accepted}
await self._parse_metadata_from_files(data, root_path=consume_path) await self._parse_metadata_from_files(data, root_path=consume_path)
await self._save_cover_image(data) await self._save_cover_image(data)
@@ -1446,7 +1472,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
book = await super().create(data) book = await super().create(data)
result.books.append(book) result.books.append(book)
await self._record_possible_duplicates(result.possible_duplicates, book, library) await self._record_possible_duplicates(
result.possible_duplicates, book, library
)
await self.repository.session.commit() await self.repository.session.commit()
@@ -1554,17 +1582,11 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# What the catalogue claims and what is on disk can disagree: Calibre keeps the # What the catalogue claims and what is on disk can disagree: Calibre keeps the
# row when a file is moved away behind its back. # row when a file is moved away behind its back.
present = [ present = [
file.path file.path for file in entry.files if await aios.path.isfile(file.path)
for file in entry.files
if await aios.path.isfile(file.path)
] ]
if not present: if not present:
reason = ( reason = "no files on disk" if entry.files else "no files in the catalogue"
"no files on disk"
if entry.files
else "no files in the catalogue"
)
result.skipped.append( result.skipped.append(
UnimportedBook( UnimportedBook(
calibre_id=entry.calibre_id, title=entry.title, reason=reason calibre_id=entry.calibre_id, title=entry.title, reason=reason
@@ -1918,9 +1940,13 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# TODO: Move only the files associated with the book instead of the whole directory # TODO: Move only the files associated with the book instead of the whole directory
await move_dir_contents(book.path, updated_path) await move_dir_contents(book.path, updated_path)
data["path"] = str(updated_path) data["path"] = str(updated_path)
cleanup_empty_parent_directories(Path(book.path), Path(library.root_path)) cleanup_empty_parent_directories(
Path(book.path), Path(library.root_path)
)
return await super().update(data, item_id=book_id, execution_options={"populate_existing": True}) return await super().update(
data, item_id=book_id, execution_options={"populate_existing": True}
)
async def add_files( async def add_files(
self, self,
@@ -1979,7 +2005,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
data["files"] = files data["files"] = files
new_files = await self._save_book_files(library, data, fingerprints) new_files = await self._save_book_files(library, data, fingerprints)
book.files.extend(new_files) book.files.extend(new_files)
await self.update_book(book.id, {"files": [file for file in book.files]}, library) await self.update_book(
book.id, {"files": [file for file in book.files]}, library
)
@staticmethod @staticmethod
async def _restore_file( async def _restore_file(
@@ -2082,7 +2110,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
return data return data
async def _populate_with_unique_relationships(self, data: ModelDictT[Book]) -> ModelDictT[Book]: async def _populate_with_unique_relationships(
self, data: ModelDictT[Book]
) -> ModelDictT[Book]:
""" """
Ensure relationship entities (authors, series, tags, etc.) are unique in the database. Ensure relationship entities (authors, series, tags, etc.) are unique in the database.
@@ -2307,7 +2337,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
data["files"] = file_metadata data["files"] = file_metadata
return data["files"] return data["files"]
async def _parse_metadata_from_files(self, data: dict, root_path: Path | None = None) -> dict: async def _parse_metadata_from_files(
self, data: dict, root_path: Path | None = None
) -> dict:
""" """
Extract metadata (title, author, etc.) from book files. Extract metadata (title, author, etc.) from book files.
@@ -2319,7 +2351,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
Returns: Returns:
The data with extracted metadata populated in empty fields. The data with extracted metadata populated in empty fields.
""" """
extracted_metadata = await MetadataExtractor.extract_metadata(data["files"], root_path) extracted_metadata = await MetadataExtractor.extract_metadata(
data["files"], root_path
)
# Add missing fields and update empty (falsey) fields with extracted metadata # Add missing fields and update empty (falsey) fields with extracted metadata
for attr in extracted_metadata.keys(): for attr in extracted_metadata.keys():
-5
View File
@@ -1,13 +1,8 @@
# src/chitai/services/bookshelf.py # src/chitai/services/bookshelf.py
# Third-party libraries # Third-party libraries
from typing import Any, Sequence
from advanced_alchemy.exceptions import ErrorMessages
from advanced_alchemy.filters import StatementFilter
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
from advanced_alchemy.repository import SQLAlchemyAsyncRepository from advanced_alchemy.repository import SQLAlchemyAsyncRepository
from advanced_alchemy.utils.dataclass import Empty, EmptyType
from sqlalchemy import ColumnElement, Select, delete
# Local imports # Local imports
from chitai.database.models.book_list import BookList, BookListLink from chitai.database.models.book_list import BookList, BookListLink
+19 -4
View File
@@ -297,7 +297,9 @@ class CalibreLibrary:
async def count(self) -> int: async def count(self) -> int:
"""How many books the catalogue holds, without reading any of them.""" """How many books the catalogue holds, without reading any of them."""
rows = await self._in_thread(lambda: self._execute("SELECT count(*) FROM books")) rows = await self._in_thread(
lambda: self._execute("SELECT count(*) FROM books")
)
return int(rows[0][0]) return int(rows[0][0])
async def books(self) -> list[CalibreBook]: async def books(self) -> list[CalibreBook]:
@@ -393,7 +395,9 @@ class CalibreLibrary:
calibre_id=book_id, calibre_id=book_id,
uuid=str(uuid or ""), uuid=str(uuid or ""),
title=str(title or ""), title=str(title or ""),
authors=[unescape_author(name) for name in authors.get(book_id, [])], authors=[
unescape_author(name) for name in authors.get(book_id, [])
],
description=strip_html(descriptions.get(book_id)), description=strip_html(descriptions.get(book_id)),
published_date=parse_date(pubdate), published_date=parse_date(pubdate),
series=in_series, series=in_series,
@@ -510,8 +514,19 @@ class _TextExtractor(HTMLParser):
# a description of three paragraphs comes out as one run-on sentence. # a description of three paragraphs comes out as one run-on sentence.
_BREAKS = frozenset( _BREAKS = frozenset(
{ {
"p", "br", "div", "li", "tr", "blockquote", "hr", "p",
"h1", "h2", "h3", "h4", "h5", "h6", "br",
"div",
"li",
"tr",
"blockquote",
"hr",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
} }
) )
+8 -4
View File
@@ -4,14 +4,20 @@ from collections import defaultdict
from chitai.config import settings from chitai.config import settings
from chitai.database.models.library import Library from chitai.database.models.library import Library
from chitai.services import BookService, LibraryService from chitai.services import BookService, LibraryService
from chitai.services.metadata_extractor import Extractor
from chitai.services.utils import create_directory from chitai.services.utils import create_directory
from watchfiles import awatch, Change from watchfiles import awatch, Change
class ConsumeDirectoryWatcher: class ConsumeDirectoryWatcher:
"""Watches a directory and batch processes files by their relative path.""" """Watches a directory and batch processes files by their relative path."""
def __init__(self, watch_path: str, library_service: LibraryService, book_service: BookService, batch_delay: float = 3.0): def __init__(
self,
watch_path: str,
library_service: LibraryService,
book_service: BookService,
batch_delay: float = 3.0,
):
""" """
Initialize the file watcher. Initialize the file watcher.
@@ -111,7 +117,6 @@ class ConsumeDirectoryWatcher:
async def _process_batch(self, file_paths: set[Path], library_slug: str): async def _process_batch(self, file_paths: set[Path], library_slug: str):
"""Process a batch of files.""" """Process a batch of files."""
try: try:
result = await self.book_service.create_many_from_existing_files( result = await self.book_service.create_many_from_existing_files(
list(file_paths), list(file_paths),
self.watch_path / Path(library_slug), self.watch_path / Path(library_slug),
@@ -138,7 +143,6 @@ class ConsumeDirectoryWatcher:
f"already be in the library as: {names}" f"already be in the library as: {names}"
) )
except Exception as e: except Exception as e:
print(f"Error processing batch: {e}") print(f"Error processing batch: {e}")
raise e raise e
+2 -5
View File
@@ -2,7 +2,7 @@
# Standard library # Standard library
from __future__ import annotations from __future__ import annotations
from typing import Any, AsyncGenerator, Callable, NotRequired, Optional from typing import Any, AsyncGenerator, Optional
# Third-party libraries # Third-party libraries
from advanced_alchemy.extensions.litestar.providers import ( from advanced_alchemy.extensions.litestar.providers import (
@@ -13,7 +13,6 @@ from advanced_alchemy.extensions.litestar.providers import (
) )
from advanced_alchemy.exceptions import NotFoundError from advanced_alchemy.exceptions import NotFoundError
from advanced_alchemy.filters import CollectionFilter, StatementFilter from advanced_alchemy.filters import CollectionFilter, StatementFilter
from advanced_alchemy.service import FilterTypeT
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from litestar import Request from litestar import Request
@@ -25,7 +24,6 @@ from litestar.di import Provide
from advanced_alchemy.extensions.litestar.providers import create_filter_dependencies from advanced_alchemy.extensions.litestar.providers import create_filter_dependencies
# Local imports # Local imports
from chitai import schemas as s
from chitai.database import models as m from chitai.database import models as m
from chitai.services import ( from chitai.services import (
UserService, UserService,
@@ -154,7 +152,6 @@ def create_book_filter_dependencies(
# OVERRIDE: Custom search filter with trigram search # OVERRIDE: Custom search filter with trigram search
if config.get("search"): if config.get("search"):
search_fields = config.get("search")
def provide_trigram_search_filter( def provide_trigram_search_filter(
search_string: str | None = Parameter( search_string: str | None = Parameter(
@@ -370,6 +367,7 @@ def provide_optional_user(request: Request[m.User, Token, Any]) -> m.User | None
return None return None
async def provide_user_via_basic_auth(request: Request[m.User, None, Any]) -> m.User: async def provide_user_via_basic_auth(request: Request[m.User, None, Any]) -> m.User:
return request.user return request.user
@@ -381,4 +379,3 @@ async def provide_user_via_kosync_auth(request: Request[m.User, None, Any]) -> m
provide_kosync_device_service = create_service_provider(KosyncDeviceService) provide_kosync_device_service = create_service_provider(KosyncDeviceService)
provide_kosync_progress_service = create_service_provider(KosyncProgressService) provide_kosync_progress_service = create_service_provider(KosyncProgressService)
@@ -4,9 +4,7 @@ from pathlib import Path
import re import re
from jinja2 import Template from jinja2 import Template
from advanced_alchemy.service import ModelDictT
import chitai.database.models as m
# TODO: Replace Jinja2 templates with a simpler custom templating system. # TODO: Replace Jinja2 templates with a simpler custom templating system.
# Current Jinja2 implementation is overly complex for basic path generation. # Current Jinja2 implementation is overly complex for basic path generation.
@@ -1,4 +1,4 @@
from typing import Any, Optional from typing import Optional
from dataclasses import dataclass from dataclasses import dataclass
from advanced_alchemy.filters import ( from advanced_alchemy.filters import (
+9 -5
View File
@@ -3,11 +3,10 @@ from typing import Any, Optional
from dataclasses import dataclass, field from dataclasses import dataclass, field
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from sqlalchemy import Select, and_, desc, func, or_, text from sqlalchemy import Select, and_, func, or_, text
from advanced_alchemy.filters import ( from advanced_alchemy.filters import (
StatementTypeT, StatementTypeT,
StatementFilter, StatementFilter,
CollectionFilter,
ModelT, ModelT,
) )
@@ -135,7 +134,7 @@ class ProgressFilter(StatementFilter):
status_conditions.append( status_conditions.append(
and_( and_(
or_( or_(
m.BookProgress.completed == False, m.BookProgress.completed.is_(False),
m.BookProgress.completed.is_(None), m.BookProgress.completed.is_(None),
), ),
m.BookProgress.percentage > 0, m.BookProgress.percentage > 0,
@@ -143,7 +142,7 @@ class ProgressFilter(StatementFilter):
) )
if ProgressStatus.READ in self.statuses: if ProgressStatus.READ in self.statuses:
status_conditions.append(m.BookProgress.completed == True) status_conditions.append(m.BookProgress.completed.is_(True))
if ProgressStatus.UNREAD in self.statuses: if ProgressStatus.UNREAD in self.statuses:
status_conditions.append(m.BookProgress.id.is_(None)) status_conditions.append(m.BookProgress.id.is_(None))
@@ -154,6 +153,7 @@ class ProgressFilter(StatementFilter):
@dataclass @dataclass
class FileFilter(StatementFilter): class FileFilter(StatementFilter):
"""Filter books that are related to the given files.""" """Filter books that are related to the given files."""
file_ids: list[int] file_ids: list[int]
def append_to_statement( def append_to_statement(
@@ -165,17 +165,21 @@ class FileFilter(StatementFilter):
return super().append_to_statement(statement, model, *args, **kwargs) return super().append_to_statement(statement, model, *args, **kwargs)
@dataclass @dataclass
class FileHashFilter(StatementFilter): class FileHashFilter(StatementFilter):
file_hashes: list[str] file_hashes: list[str]
def append_to_statement(self, statement: StatementTypeT, model: type[ModelT], *args, **kwargs) -> StatementTypeT: def append_to_statement(
self, statement: StatementTypeT, model: type[ModelT], *args, **kwargs
) -> StatementTypeT:
statement = statement.where( statement = statement.where(
m.Book.files.any(m.FileMetadata.hash.in_(self.file_hashes)) m.Book.files.any(m.FileMetadata.hash.in_(self.file_hashes))
) )
return super().append_to_statement(statement, model, *args, **kwargs) return super().append_to_statement(statement, model, *args, **kwargs)
@dataclass @dataclass
class CustomOrderBy(StatementFilter): class CustomOrderBy(StatementFilter):
"""Order by filter with support for 'random' and 'last accessed' orderings.""" """Order by filter with support for 'random' and 'last accessed' orderings."""
+8 -4
View File
@@ -1,16 +1,21 @@
from __future__ import annotations from __future__ import annotations
import secrets import secrets
from chitai.database.models.kosync_device import KosyncDevice from chitai.database.models.kosync_device import KosyncDevice
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService, ModelDictT, schema_dump from advanced_alchemy.service import (
SQLAlchemyAsyncRepositoryService,
ModelDictT,
schema_dump,
)
from advanced_alchemy.repository import SQLAlchemyAsyncRepository from advanced_alchemy.repository import SQLAlchemyAsyncRepository
class KosyncDeviceService(SQLAlchemyAsyncRepositoryService[KosyncDevice]): class KosyncDeviceService(SQLAlchemyAsyncRepositoryService[KosyncDevice]):
"""Service for managing KOReader devices.""" """Service for managing KOReader devices."""
API_KEY_LENGTH_IN_BYTES = 8 API_KEY_LENGTH_IN_BYTES = 8
class Repo(SQLAlchemyAsyncRepository[KosyncDevice]): class Repo(SQLAlchemyAsyncRepository[KosyncDevice]):
""" Repository for KosyncDevice entities.""" """Repository for KosyncDevice entities."""
model_type = KosyncDevice model_type = KosyncDevice
@@ -18,7 +23,7 @@ class KosyncDeviceService(SQLAlchemyAsyncRepositoryService[KosyncDevice]):
async def create(self, data: ModelDictT[KosyncDevice], **kwargs) -> KosyncDevice: async def create(self, data: ModelDictT[KosyncDevice], **kwargs) -> KosyncDevice:
data = schema_dump(data) data = schema_dump(data)
data['api_key'] = self._generate_api_key() data["api_key"] = self._generate_api_key()
return await super().create(data, **kwargs) return await super().create(data, **kwargs)
async def get_by_api_key(self, api_key: str) -> KosyncDevice: async def get_by_api_key(self, api_key: str) -> KosyncDevice:
@@ -30,6 +35,5 @@ class KosyncDeviceService(SQLAlchemyAsyncRepositoryService[KosyncDevice]):
device.api_key = api_key device.api_key = api_key
return await self.update(device) return await self.update(device)
def _generate_api_key(self) -> str: def _generate_api_key(self) -> str:
return secrets.token_hex(self.API_KEY_LENGTH_IN_BYTES) return secrets.token_hex(self.API_KEY_LENGTH_IN_BYTES)
@@ -16,7 +16,9 @@ class KosyncProgressService(SQLAlchemyAsyncRepositoryService[KosyncProgress]):
repository_type = Repo repository_type = Repo
async def get_by_document_hash(self, user_id: int, document: str) -> KosyncProgress | None: async def get_by_document_hash(
self, user_id: int, document: str
) -> KosyncProgress | None:
"""Get progress for a specific document and user.""" """Get progress for a specific document and user."""
return await self.get_one_or_none( return await self.get_one_or_none(
KosyncProgress.user_id == user_id, KosyncProgress.user_id == user_id,
+2 -6
View File
@@ -5,7 +5,6 @@ from pathlib import Path
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
from advanced_alchemy.repository import SQLAlchemyAsyncRepository from advanced_alchemy.repository import SQLAlchemyAsyncRepository
from advanced_alchemy import service from advanced_alchemy import service
from advanced_alchemy.utils.text import slugify
# Local imports # Local imports
from chitai.database.models.library import Library from chitai.database.models.library import Library
@@ -18,6 +17,7 @@ from chitai.services.utils import (
from chitai.config import settings from chitai.config import settings
class LibraryService(SQLAlchemyAsyncRepositoryService[Library]): class LibraryService(SQLAlchemyAsyncRepositoryService[Library]):
"""Service for managing libraries and their configuration.""" """Service for managing libraries and their configuration."""
@@ -48,7 +48,7 @@ class LibraryService(SQLAlchemyAsyncRepositoryService[Library]):
""" """
# TODO: What if a library root_path is a child of an existing library? # TODO: What if a library root_path is a child of an existing library?
if existing := await self.list(Library.root_path == library.root_path): if await self.list(Library.root_path == library.root_path):
raise ValueError(f"Library already exists at '{library.root_path}'") raise ValueError(f"Library already exists at '{library.root_path}'")
if library.read_only: if library.read_only:
@@ -58,7 +58,6 @@ class LibraryService(SQLAlchemyAsyncRepositoryService[Library]):
f"Root directory '{library.root_path}' must exist for a read-only library" f"Root directory '{library.root_path}' must exist for a read-only library"
) )
# TODO: Verify the read-only library has read permissions # TODO: Verify the read-only library has read permissions
created_library = await super().create( created_library = await super().create(
service.schema_dump(library, exclude_unset=False), **kwargs service.schema_dump(library, exclude_unset=False), **kwargs
@@ -72,9 +71,6 @@ class LibraryService(SQLAlchemyAsyncRepositoryService[Library]):
return created_library return created_library
# TODO: Implement library deletion and optional file deletion # TODO: Implement library deletion and optional file deletion
async def delete( async def delete(
self, item_id: int, delete_files: bool = False, **kwargs self, item_id: int, delete_files: bool = False, **kwargs
@@ -103,8 +103,18 @@ def parse_identifier(value: str, scheme: str | None = None) -> tuple[str, str] |
# Numbered editions, in the forms covers and catalogue records actually use: # Numbered editions, in the forms covers and catalogue records actually use:
# "3rd Edition", "2E", "4e", "8_e", "/6e", "(2nd edition)", "Third International Edition". # "3rd Edition", "2E", "4e", "8_e", "/6e", "(2nd edition)", "Third International Edition".
_ORDINAL_WORDS = { _ORDINAL_WORDS = {
"first": 1, "second": 2, "third": 3, "fourth": 4, "fifth": 5, "sixth": 6, "first": 1,
"seventh": 7, "eighth": 8, "ninth": 9, "tenth": 10, "eleventh": 11, "twelfth": 12, "second": 2,
"third": 3,
"fourth": 4,
"fifth": 5,
"sixth": 6,
"seventh": 7,
"eighth": 8,
"ninth": 9,
"tenth": 10,
"eleventh": 11,
"twelfth": 12,
} }
# Words that sit between the number and "Edition" and belong to the same statement. # Words that sit between the number and "Edition" and belong to the same statement.
@@ -161,7 +171,9 @@ def split_edition(title: str | None) -> tuple[str | None, int | None]:
stripped = _EDITION.sub(" ", title) stripped = _EDITION.sub(" ", title)
stripped = re.sub(r"\s{2,}", " ", stripped) stripped = re.sub(r"\s{2,}", " ", stripped)
stripped = re.sub(r"\s+([,;:.!?])", r"\1", stripped) # "Works : What" → "Works: What" stripped = re.sub(
r"\s+([,;:.!?])", r"\1", stripped
) # "Works : What" → "Works: What"
stripped = stripped.strip(" ,;:-–—/") stripped = stripped.strip(" ,;:-–—/")
# A title that is only an edition statement is not improved by having none. # A title that is only an edition statement is not improved by having none.
@@ -178,7 +190,9 @@ class FileExtractor(Protocol):
) -> dict[str, Any]: ... ) -> dict[str, Any]: ...
@classmethod @classmethod
async def extract_text(cls, input: UploadFile | BinaryIO | bytes | Path | str) -> str: ... async def extract_text(
cls, input: UploadFile | BinaryIO | bytes | Path | str
) -> str: ...
class Extractor: class Extractor:
@@ -187,7 +201,9 @@ class Extractor:
format_priorities = {"epub": 1, "pdf": 2} format_priorities = {"epub": 1, "pdf": 2}
@classmethod @classmethod
async def extract_metadata(cls, files: list[UploadFile] | list[Path], root_path: Path | None = None) -> dict[str, Any]: async def extract_metadata(
cls, files: list[UploadFile] | list[Path], root_path: Path | None = None
) -> dict[str, Any]:
metadata = {} metadata = {}
# Sort based on file priority # Sort based on file priority
@@ -235,7 +251,7 @@ class Extractor:
metadata = FilepathExtractor.extract_metadata(files[0], root_path) | metadata metadata = FilepathExtractor.extract_metadata(files[0], root_path) | metadata
# format the title # format the title
if metadata.get('title', None): if metadata.get("title", None):
# Before the subtitle split, so the edition cannot be mistaken for one: # Before the subtitle split, so the edition cannot be mistaken for one:
# "How Linux Works, 3rd Edition: What Every Superuser Should Know" has to # "How Linux Works, 3rd Edition: What Every Superuser Should Know" has to
# lose the edition first for the colon count to mean anything. # lose the edition first for the colon count to mean anything.
@@ -282,7 +298,7 @@ class Extractor:
file_ext = get_file_extension(filename) file_ext = get_file_extension(filename)
if file_ext is None: if file_ext is None:
return float('inf') return float("inf")
return Extractor.format_priorities.get(file_ext, float("inf")) return Extractor.format_priorities.get(file_ext, float("inf"))
@@ -618,7 +634,9 @@ class FilepathExtractor(FileExtractor):
"""Extracts metadata from the filepath.""" """Extracts metadata from the filepath."""
@classmethod @classmethod
def extract_metadata(cls, input: UploadFile | Path | str, root_path: Path | None = None) -> dict[str, Any]: def extract_metadata(
cls, input: UploadFile | Path | str, root_path: Path | None = None
) -> dict[str, Any]:
if isinstance(input, UploadFile): if isinstance(input, UploadFile):
path = Path(input.filename).parent path = Path(input.filename).parent
@@ -633,27 +651,28 @@ class FilepathExtractor(FileExtractor):
if len(parts) == 3: if len(parts) == 3:
# Format: Author/Series/Part - Title/filename # Format: Author/Series/Part - Title/filename
metadata['author'] = parts[0] metadata["author"] = parts[0]
# Extract part number and title from directory name (parts[2]) # Extract part number and title from directory name (parts[2])
dirname = parts[2] dirname = parts[2]
match = re.match(r'^([\d.]+)\s*-\s*(.+)$', dirname) match = re.match(r"^([\d.]+)\s*-\s*(.+)$", dirname)
if match: if match:
metadata['series_position'] = match.group(1) # Keep as string metadata["series_position"] = match.group(1) # Keep as string
metadata['series'] = parts[1] metadata["series"] = parts[1]
metadata['title'] = match.group(2).strip() metadata["title"] = match.group(2).strip()
else: else:
metadata['series'] = parts[1] metadata["series"] = parts[1]
metadata['title'] = path.stem metadata["title"] = path.stem
elif len(parts) == 2: elif len(parts) == 2:
# Format: Author/Title # Format: Author/Title
metadata['author'] = parts[0] metadata["author"] = parts[0]
metadata['title'] = path.stem # Remove extension metadata["title"] = path.stem # Remove extension
return metadata return metadata
class FilenameExtractor(FileExtractor): class FilenameExtractor(FileExtractor):
"""Extracts metadata from the filename.""" """Extracts metadata from the filename."""
+26 -12
View File
@@ -4,27 +4,39 @@ from typing import Any, Literal, Optional, Sequence
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from datetime import datetime from datetime import datetime
class LinkTypes(StrEnum): class LinkTypes(StrEnum):
NAVIGATION = "application/atom+xml;profile=opds-catalog;kind=navigation" NAVIGATION = "application/atom+xml;profile=opds-catalog;kind=navigation"
ACQUISITION = "application/atom+xml;profile=opds-catalog;kind=acquisition" ACQUISITION = "application/atom+xml;profile=opds-catalog;kind=acquisition"
OPEN_SEARCH = "application/opensearchdescription+xml" OPEN_SEARCH = "application/opensearchdescription+xml"
class AcquisitionRelations(StrEnum): class AcquisitionRelations(StrEnum):
_BASE = "http://opds-spec.org/acquisition" _BASE = "http://opds-spec.org/acquisition"
ACQUISITION = _BASE # A generic relation that indicates that the entry may be retrieved ACQUISITION = (
OPEN_ACCESS = f"{_BASE}/open-access" # Entry may be retrieved without any requirement _BASE # A generic relation that indicates that the entry may be retrieved
BORROW = f"{_BASE}/borrow" # Entry may be retrieved as part of a lending transaction )
OPEN_ACCESS = (
f"{_BASE}/open-access" # Entry may be retrieved without any requirement
)
BORROW = (
f"{_BASE}/borrow" # Entry may be retrieved as part of a lending transaction
)
BUY = f"{_BASE}/buy" # Entry may be retrieved as part of a purchase BUY = f"{_BASE}/buy" # Entry may be retrieved as part of a purchase
SAMPLE = f"{_BASE}/sample" # Subset of the entry may be retrieved SAMPLE = f"{_BASE}/sample" # Subset of the entry may be retrieved
PREVIEW = f"{_BASE}/preview" # Subset of the entry may be retrieved PREVIEW = f"{_BASE}/preview" # Subset of the entry may be retrieved
SUBSCRIBE = f"{_BASE}/subscribe" # Entry my be retrieved as a part of a subscription SUBSCRIBE = (
f"{_BASE}/subscribe" # Entry my be retrieved as a part of a subscription
)
class NavigationRelations(StrEnum): class NavigationRelations(StrEnum):
_BASE = "" _BASE = ""
class LinkRelations(StrEnum): class LinkRelations(StrEnum):
""" Link types for OPDSv1.2 related resources """Link types for OPDSv1.2 related resources
https://specs.opds.io/opds-1.2.html#6-additional-link-relations https://specs.opds.io/opds-1.2.html#6-additional-link-relations
""" """
@@ -32,17 +44,19 @@ class LinkRelations(StrEnum):
_BASE = "http://opds-spec.org" _BASE = "http://opds-spec.org"
START = "start" # The OPDS catalog root START = "start" # The OPDS catalog root
SUBSECTION = "subsection" # an OPDS feed not better described by any of the below relations SUBSECTION = (
"subsection" # an OPDS feed not better described by any of the below relations
)
SHELF = f"{_BASE}/shelf" # Entries acquired by the euser SHELF = f"{_BASE}/shelf" # Entries acquired by the euser
SUBSCRIPTIONS = f"{_BASE}/subscriptions" # Entries available with users's subscription SUBSCRIPTIONS = (
f"{_BASE}/subscriptions" # Entries available with users's subscription
)
NEW = f"{_BASE}/sort/new" # Newest entries NEW = f"{_BASE}/sort/new" # Newest entries
POPULAR = f"{_BASE}/sort/popular" # Most popular entries POPULAR = f"{_BASE}/sort/popular" # Most popular entries
FEATURED = f"{_BASE}/featured" # Entries selected for promotion FEATURED = f"{_BASE}/featured" # Entries selected for promotion
RECOMMENDED = f"{_BASE}/recommended" # Entries recommended to the specific user RECOMMENDED = f"{_BASE}/recommended" # Entries recommended to the specific user
class Feed(BaseModel): # OPDS Catalog root element class Feed(BaseModel): # OPDS Catalog root element
xmlns: Literal["http://www.w3.org/2005/Atom"] = Field( xmlns: Literal["http://www.w3.org/2005/Atom"] = Field(
default="http://www.w3.org/2005/Atom", serialization_alias="@xmlns" default="http://www.w3.org/2005/Atom", serialization_alias="@xmlns"
@@ -117,9 +131,8 @@ class AcquisitionFeedLink(Link):
class NavigationFeedLink(Link): class NavigationFeedLink(Link):
type: str = Field( type: str = Field(default=LinkTypes.NAVIGATION, serialization_alias="@type")
default=LinkTypes.NAVIGATION, serialization_alias="@type"
)
class Content(BaseModel): class Content(BaseModel):
type: Literal["text"] = Field(default="text", serialization_alias="@type") type: Literal["text"] = Field(default="text", serialization_alias="@type")
@@ -170,6 +183,7 @@ class Entry(BaseModel):
data = super().model_dump(**kwargs) data = super().model_dump(**kwargs)
return {"entry": data} return {"entry": data}
@dataclass @dataclass
class PaginationResult: class PaginationResult:
next_link: Optional[Link] next_link: Optional[Link]
+41 -54
View File
@@ -1,4 +1,3 @@
from typing import Any, Callable, Sequence from typing import Any, Callable, Sequence
from urllib.parse import quote_plus, urlencode from urllib.parse import quote_plus, urlencode
from litestar import Request from litestar import Request
@@ -16,9 +15,10 @@ from .models import (
AcquisitionFeedLink, AcquisitionFeedLink,
NavigationFeed, NavigationFeed,
NavigationFeedLink, NavigationFeedLink,
PaginationResult PaginationResult,
) )
def get_opensearch_document(base_url: str = "/opds/search?") -> str: def get_opensearch_document(base_url: str = "/opds/search?") -> str:
search = { search = {
"OpenSearchDescription": { "OpenSearchDescription": {
@@ -120,19 +120,20 @@ def create_navigation_feed(
pretty=True, pretty=True,
) )
def create_library_navigation_feed(library: m.Library) -> str: def create_library_navigation_feed(library: m.Library) -> str:
entries = [ entries = [
Entry( Entry(
id=f"/opds/library/{library.id}/all-books", id=f"/opds/library/{library.id}/all-books",
title='All Books', title="All Books",
link=[ link=[
AcquisitionFeedLink( AcquisitionFeedLink(
rel="subsection", rel="subsection",
href=f"/opds/acquisition?libraries={library.id}&paginated=1&pageSize=50&feed_title=AllBooks&feed_id=/opds/library/{library.id}/all-books", href=f"/opds/acquisition?libraries={library.id}&paginated=1&pageSize=50&feed_title=AllBooks&feed_id=/opds/library/{library.id}/all-books",
title="All Books", title="All Books",
) )
] ],
), ),
Entry( Entry(
id=f"/opds/library/{library.id}/recently-added", id=f"/opds/library/{library.id}/recently-added",
@@ -141,9 +142,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink( NavigationFeedLink(
rel="http://opds-spec.org/sort/new", rel="http://opds-spec.org/sort/new",
href=f"/opds/acquisition?libraries={library.id}&orderBy=created_at&pageSize=50&feed_title=RecentlyAdded&feed_id=/opds/library/{library.id}/recently-added", href=f"/opds/acquisition?libraries={library.id}&orderBy=created_at&pageSize=50&feed_title=RecentlyAdded&feed_id=/opds/library/{library.id}/recently-added",
title="Recently Added" title="Recently Added",
) )
] ],
), ),
Entry( Entry(
id=f"/opds/library/{library.id}/shelves", id=f"/opds/library/{library.id}/shelves",
@@ -152,9 +153,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink( NavigationFeedLink(
rel="subsection", rel="subsection",
href=f"/opds/library/{library.id}/shelves?paginated=1&pageSize=10", href=f"/opds/library/{library.id}/shelves?paginated=1&pageSize=10",
title="Bookshelves" title="Bookshelves",
) )
] ],
), ),
Entry( Entry(
id=f"/opds/library/{library.id}/tags", id=f"/opds/library/{library.id}/tags",
@@ -163,9 +164,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink( NavigationFeedLink(
rel="subsection", rel="subsection",
href=f"/opds/library/{library.id}/tags?paginated=1&pageSize=10", href=f"/opds/library/{library.id}/tags?paginated=1&pageSize=10",
title="Tags" title="Tags",
) )
] ],
), ),
Entry( Entry(
id=f"/opds/library/{library.id}/authors", id=f"/opds/library/{library.id}/authors",
@@ -174,9 +175,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink( NavigationFeedLink(
rel="subsection", rel="subsection",
href=f"/opds/library/{library.id}/authors?paginated=1&pageSize=10", href=f"/opds/library/{library.id}/authors?paginated=1&pageSize=10",
title="Authors" title="Authors",
) )
] ],
), ),
Entry( Entry(
id=f"/opds/library/{library.id}/publishers", id=f"/opds/library/{library.id}/publishers",
@@ -185,32 +186,32 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink( NavigationFeedLink(
rel="subsection", rel="subsection",
href=f"/opds/library/{library.id}/publishers?paginated=1&pageSize=10", href=f"/opds/library/{library.id}/publishers?paginated=1&pageSize=10",
title="Publishers" title="Publishers",
) )
] ],
), ),
] ]
feed = create_navigation_feed( feed = create_navigation_feed(
id=f'/library/{library.id}', id=f"/library/{library.id}",
title=library.name, title=library.name,
self_url=f'/opds/library/{library.id}', self_url=f"/opds/library/{library.id}",
links=[ links=[],
entries=entries,
],
entries=entries
) )
return feed return feed
def create_collection_navigation_feed( def create_collection_navigation_feed(
library: m.Library, library: m.Library,
collection_type: str, collection_type: str,
items: Sequence[m.BookList | m.Tag | m.Author | m.Publisher | m.BookSeries], items: Sequence[m.BookList | m.Tag | m.Author | m.Publisher | m.BookSeries],
links: list[Link] = list(), links: list[Link] = list(),
# Title is usually derived from the model's name or title # Title is usually derived from the model's name or title
get_title: Callable[[Any], str] = lambda x: getattr(x, 'title', getattr(x, 'name', str(x))) get_title: Callable[[Any], str] = lambda x: getattr(
x, "title", getattr(x, "name", str(x))
),
) -> str: ) -> str:
entries = [ entries = [
@@ -222,52 +223,42 @@ def create_collection_navigation_feed(
href=f"/opds/acquisition?{collection_type}={item.id}&pageSize=50&paginated=1&feed_title={quote_plus(get_title(item))}&feed_id=/opds/library/{library.id}/{collection_type}/{item.id}&search=True", href=f"/opds/acquisition?{collection_type}={item.id}&pageSize=50&paginated=1&feed_title={quote_plus(get_title(item))}&feed_id=/opds/library/{library.id}/{collection_type}/{item.id}&search=True",
title=get_title(item), title=get_title(item),
) )
] ],
) for item in items )
for item in items
] ]
return create_navigation_feed( return create_navigation_feed(
id=f"/opds/library/{library.id}/{collection_type}", id=f"/opds/library/{library.id}/{collection_type}",
title=collection_type.title(), title=collection_type.title(),
self_url=f'/opds/library/{library.id}/{collection_type}', self_url=f"/opds/library/{library.id}/{collection_type}",
entries=entries, entries=entries,
links=links links=links,
) )
def create_next_paginated_link( def create_next_paginated_link(
request: Request, request: Request, total: int, current_count: int, offset: int, feed_title: str
total: int, ) -> Link | None:
current_count: int,
offset: int,
feed_title: str
) -> Link | None:
if total <= current_count + offset: if total <= current_count + offset:
return None return None
params = dict(request.query_params) params = dict(request.query_params)
params['currentPage'] = params.get('currentPage', 1) + 1 params["currentPage"] = params.get("currentPage", 1) + 1
next_url = f"{request.url.path}?{urlencode(list(params.items()), doseq=True)}" next_url = f"{request.url.path}?{urlencode(list(params.items()), doseq=True)}"
return Link( return Link(rel="next", href=next_url, title=feed_title, type=LinkTypes.NAVIGATION)
rel="next",
href=next_url,
title=feed_title,
type=LinkTypes.NAVIGATION
)
def create_search_link( def create_search_link(
request: Request, request: Request, exclude_params: set[str] | None = None
exclude_params: set[str] | None = None
) -> Link: ) -> Link:
"""Create search link with current filters applied""" """Create search link with current filters applied"""
if exclude_params is None: if exclude_params is None:
exclude_params = {'currentPage', 'feed_title', 'feed_id', 'search', 'paginated'} exclude_params = {"currentPage", "feed_title", "feed_id", "search", "paginated"}
params = { params = {k: v for k, v in request.query_params.items() if k not in exclude_params}
k: v for k, v in request.query_params.items()
if k not in exclude_params
}
return Link( return Link(
rel="search", rel="search",
@@ -277,7 +268,6 @@ def create_search_link(
) )
def create_pagination_links( def create_pagination_links(
request: Request, request: Request,
total: int, total: int,
@@ -296,14 +286,11 @@ def create_pagination_links(
params = dict(request.query_params) params = dict(request.query_params)
# Calculate next page number # Calculate next page number
current_page = (offset // limit) + 1 current_page = (offset // limit) + 1
params['currentPage'] = current_page + 1 params["currentPage"] = current_page + 1
next_url = f"{request.url.path}?{urlencode(params, doseq=True)}" next_url = f"{request.url.path}?{urlencode(params, doseq=True)}"
next_link = Link( next_link = Link(
rel="next", rel="next", href=next_url, title=f"{feed_title} - Next", type=link_type
href=next_url,
title=f"{feed_title} - Next",
type=link_type
) )
# Create previous link if not on first page # Create previous link if not on first page
@@ -311,14 +298,14 @@ def create_pagination_links(
params = dict(request.query_params) params = dict(request.query_params)
# Calculate previous page number # Calculate previous page number
current_page = (offset // limit) + 1 current_page = (offset // limit) + 1
params['currentPage'] = max(1, current_page - 1) params["currentPage"] = max(1, current_page - 1)
prev_url = f"{request.url.path}?{urlencode(params, doseq=True)}" prev_url = f"{request.url.path}?{urlencode(params, doseq=True)}"
prev_link = Link( prev_link = Link(
rel="previous", rel="previous",
href=prev_url, href=prev_url,
title=f"{feed_title} - Previous", title=f"{feed_title} - Previous",
type=link_type type=link_type,
) )
return PaginationResult(next_link, prev_link, offset, total) return PaginationResult(next_link, prev_link, offset, total)
+2
View File
@@ -213,6 +213,7 @@ async def create_directory(dir_path: Path | str) -> None:
await aios.makedirs(dir_path, exist_ok=True) await aios.makedirs(dir_path, exist_ok=True)
async def move_file(src_path: Path, dest_path: Path, create_dirs=True) -> None: async def move_file(src_path: Path, dest_path: Path, create_dirs=True) -> None:
""" """
Move a file from source to destination asynchronously. Move a file from source to destination asynchronously.
@@ -239,6 +240,7 @@ async def move_file(src_path: Path, dest_path: Path, create_dirs=True) -> None:
# all configured separately, so they can easily be separate mounts. # all configured separately, so they can easily be separate mounts.
shutil.move(str(src_path), str(dest_path)) shutil.move(str(src_path), str(dest_path))
async def copy_file(src_path: Path, dest_path: Path, create_dirs: bool = True) -> None: async def copy_file(src_path: Path, dest_path: Path, create_dirs: bool = True) -> None:
""" """
Copy a file, streaming it rather than reading it whole. Copy a file, streaming it rather than reading it whole.
+2 -1
View File
@@ -2,6 +2,8 @@ from pathlib import Path
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from chitai import services
from advanced_alchemy.base import UUIDAuditBase from advanced_alchemy.base import UUIDAuditBase
from litestar.testing import AsyncTestClient from litestar.testing import AsyncTestClient
from sqlalchemy import text from sqlalchemy import text
@@ -155,7 +157,6 @@ async def other_authenticated_client(
# Service fixtures # Service fixtures
from chitai import services
@pytest.fixture @pytest.fixture
+43 -34
View File
@@ -1,6 +1,7 @@
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from pathlib import Path from pathlib import Path
from litestar.status_codes import HTTP_400_BAD_REQUEST
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -218,7 +219,9 @@ async def test_list_books_by_id(populated_authenticated_client: AsyncClient) ->
compare a bigint primary key against them. Nothing called it until a screen needed compare a bigint primary key against them. Nothing called it until a screen needed
to fetch a handful of books by id. to fetch a handful of books by id.
""" """
response = await populated_authenticated_client.get("/books?ids=1&ids=2&pageSize=10") response = await populated_authenticated_client.get(
"/books?ids=1&ids=2&pageSize=10"
)
assert response.status_code == 200 assert response.status_code == 200
assert sorted(book["id"] for book in response.json()["items"]) == [1, 2] assert sorted(book["id"] for book in response.json()["items"]) == [1, 2]
@@ -228,7 +231,7 @@ async def test_get_book_by_id(populated_authenticated_client: AsyncClient) -> No
"""Test retrieving a specific book by ID.""" """Test retrieving a specific book by ID."""
# Retrieve the book # Retrieve the book
response = await populated_authenticated_client.get(f"/books/1") response = await populated_authenticated_client.get("/books/1")
assert response.status_code == 200 assert response.status_code == 200
book_data = response.json() book_data = response.json()
@@ -300,13 +303,13 @@ async def test_delete_book_metadata_only(
# Delete book without deleting files # Delete book without deleting files
response = await populated_authenticated_client.delete( response = await populated_authenticated_client.delete(
f"/books?book_ids=3&delete_files=false&library_id=1" "/books?book_ids=3&delete_files=false&library_id=1"
) )
assert response.status_code == 204 assert response.status_code == 204
# Verify book is deleted # Verify book is deleted
get_response = await populated_authenticated_client.get(f"/books/3") get_response = await populated_authenticated_client.get("/books/3")
assert get_response.status_code == 404 assert get_response.status_code == 404
@@ -317,7 +320,7 @@ async def test_delete_book_with_files(
# Delete book and files # Delete book and files
response = await populated_authenticated_client.delete( response = await populated_authenticated_client.delete(
f"/books?book_ids=3&delete_files=true&library_id=1" "/books?book_ids=3&delete_files=true&library_id=1"
) )
assert response.status_code == 204 assert response.status_code == 204
@@ -330,7 +333,7 @@ async def test_delete_specific_book_files(
# Delete specific file # Delete specific file
response = await populated_authenticated_client.delete( response = await populated_authenticated_client.delete(
f"/books/1/files?file_ids=1", "/books/1/files?file_ids=1",
) )
assert response.status_code == 204 assert response.status_code == 204
@@ -347,7 +350,7 @@ async def test_update_reading_progress(
} }
response = await populated_authenticated_client.post( response = await populated_authenticated_client.post(
f"/books/progress/1", "/books/progress/1",
json=progress_data, json=progress_data,
) )
@@ -423,7 +426,9 @@ async def test_create_books_groups_formats_within_one_folder(
) -> None: ) -> None:
"""Picking a book's own folder yields one book with both formats, not two books.""" """Picking a book's own folder yields one book with both formats, not two books."""
epub = Path("tests/data_files/Metamorphosis - Franz Kafka.epub").read_bytes() epub = Path("tests/data_files/Metamorphosis - Franz Kafka.epub").read_bytes()
pdf = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf").read_bytes() pdf = Path(
"tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf"
).read_bytes()
files = [ files = [
("files", ("Metamorphosis/Metamorphosis.epub", epub, "application/epub+zip")), ("files", ("Metamorphosis/Metamorphosis.epub", epub, "application/epub+zip")),
@@ -534,7 +539,9 @@ class TestDuplicateHandling:
"files", "files",
( (
"war.epub", "war.epub",
Path("tests/data_files/The Art of War - Sun Tzu.epub").read_bytes(), Path(
"tests/data_files/The Art of War - Sun Tzu.epub"
).read_bytes(),
"application/epub+zip", "application/epub+zip",
), ),
) )
@@ -550,7 +557,9 @@ class TestDuplicateHandling:
"files", "files",
( (
"war.epub", "war.epub",
Path("tests/data_files/The Art of War - Sun Tzu.epub").read_bytes(), Path(
"tests/data_files/The Art of War - Sun Tzu.epub"
).read_bytes(),
"application/epub+zip", "application/epub+zip",
), ),
) )
@@ -736,7 +745,9 @@ class TestDuplicateBooks:
assert len(merged["files"]) == 2 assert len(merged["files"]) == 2
# The folded record is gone, and the group it formed with it. # The folded record is gone, and the group it formed with it.
assert (await authenticated_client.get(f"/books/{fold['id']}")).status_code == 404 assert (
await authenticated_client.get(f"/books/{fold['id']}")
).status_code == 404
groups = await authenticated_client.get("/books/duplicate-books?library_id=1") groups = await authenticated_client.get("/books/duplicate-books?library_id=1")
assert groups.json() == [] assert groups.json() == []
@@ -786,16 +797,6 @@ class TestDuplicateBooks:
# async def test_edit_book_metadata(authenticated_client: AsyncClient) -> None: # async def test_edit_book_metadata(authenticated_client: AsyncClient) -> None:
# raise NotImplementedError() # raise NotImplementedError()
import pytest
import aiofiles
from httpx import AsyncClient
from pathlib import Path
from litestar.status_codes import HTTP_400_BAD_REQUEST
import pytest
from httpx import AsyncClient
from datetime import date
class TestMetadataUpdates: class TestMetadataUpdates:
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -849,8 +850,10 @@ class TestMetadataUpdates:
( (
"authors", # Update with new authors "authors", # Update with new authors
["New Author 1", "New Author 2"], ["New Author 1", "New Author 2"],
lambda data: {a["name"] for a in data["authors"]} lambda data: (
== {"New Author 1", "New Author 2"}, {a["name"] for a in data["authors"]}
== {"New Author 1", "New Author 2"}
),
), ),
( (
"authors", # Clear authors "authors", # Clear authors
@@ -860,8 +863,9 @@ class TestMetadataUpdates:
( (
"tags", # Update with new tags "tags", # Update with new tags
["Tag 1", "Tag 2", "Tag 3"], ["Tag 1", "Tag 2", "Tag 3"],
lambda data: {t["name"] for t in data["tags"]} lambda data: (
== {"Tag 1", "Tag 2", "Tag 3"}, {t["name"] for t in data["tags"]} == {"Tag 1", "Tag 2", "Tag 3"}
),
), ),
( (
"tags", # Clear tags "tags", # Clear tags
@@ -881,8 +885,10 @@ class TestMetadataUpdates:
( (
"identifiers", # Update with new identifiers "identifiers", # Update with new identifiers
{"isbn-13": "978-1234567890", "doi": "10.example/id"}, {"isbn-13": "978-1234567890", "doi": "10.example/id"},
lambda data: data["identifiers"] lambda data: (
== {"isbn-13": "978-1234567890", "doi": "10.example/id"}, data["identifiers"]
== {"isbn-13": "978-1234567890", "doi": "10.example/id"}
),
), ),
( (
"identifiers", # Clear identifiers "identifiers", # Clear identifiers
@@ -1053,7 +1059,7 @@ class TestMetadataUpdates:
result = response.json() result = response.json()
assert result[updated_field] == None assert result[updated_field] is None
@pytest.mark.parametrize( @pytest.mark.parametrize(
("updated_field"), ("updated_field"),
@@ -1215,7 +1221,9 @@ class TestFileManagement:
pytest.skip("Book has no files") pytest.skip("Book has no files")
file_id = book_data["files"][0]["id"] file_id = book_data["files"][0]["id"]
filename = book_data["files"][0].get("path") # TODO: this test asserts only the 204 and never checks the disk, so the flag it is
# named for is untested. See TODO.md; drop the noqa when the assertion lands.
filename = book_data["files"][0].get("path") # noqa: F841
# Remove file without deleting from filesystem # Remove file without deleting from filesystem
response = await populated_authenticated_client.delete( response = await populated_authenticated_client.delete(
@@ -1240,7 +1248,8 @@ class TestFileManagement:
book_data = add_response.json() book_data = add_response.json()
file_id = book_data["files"][-1]["id"] file_id = book_data["files"][-1]["id"]
file_path = book_data["files"][-1].get("path") # TODO: as above -- the file is never checked for removal from disk.
file_path = book_data["files"][-1].get("path") # noqa: F841
# Remove file with deletion from filesystem # Remove file with deletion from filesystem
response = await populated_authenticated_client.delete( response = await populated_authenticated_client.delete(
@@ -1295,7 +1304,9 @@ class TestUnnameableFormats:
self, authenticated_client: AsyncClient self, authenticated_client: AsyncClient
) -> None: ) -> None:
response = await authenticated_client.post( response = await authenticated_client.post(
"/books?library_id=1", files=self.upload("Dune.mobi"), data={"library_id": 1} "/books?library_id=1",
files=self.upload("Dune.mobi"),
data={"library_id": 1},
) )
assert response.status_code == 201 assert response.status_code == 201
@@ -1323,9 +1334,7 @@ class TestUnnameableFormats:
assert detail.status_code == 200 assert detail.status_code == 200
assert detail.json()["files"][0]["content_type"] is None assert detail.json()["files"][0]["content_type"] is None
async def test_the_file_downloads( async def test_the_file_downloads(self, authenticated_client: AsyncClient) -> None:
self, authenticated_client: AsyncClient
) -> None:
"""Litestar supplies its own media type when the row carries none.""" """Litestar supplies its own media type when the row carries none."""
created = await authenticated_client.post( created = await authenticated_client.post(
"/books?library_id=1", "/books?library_id=1",
@@ -1,4 +1,3 @@
import pytest
from httpx import AsyncClient from httpx import AsyncClient
@@ -200,7 +199,6 @@ async def test_remove_books_from_shelf(
"/books", params={"shelves": shelf_id} "/books", params={"shelves": shelf_id}
) )
assert books_response.status_code == 200 assert books_response.status_code == 200
assert books_response.json()["total"] == 2 assert books_response.json()["total"] == 2
@@ -42,7 +42,9 @@ def fx_source(tmp_path: Path) -> Path:
cover=True, cover=True,
formats={"EPUB": EPUB}, formats={"EPUB": EPUB},
) )
fixture.add_book(2, "The Art of War", authors=["Sun Tzu"], formats={"EPUB": OTHER_EPUB}) fixture.add_book(
2, "The Art of War", authors=["Sun Tzu"], formats={"EPUB": OTHER_EPUB}
)
fixture.add_book(3, "Metadata Only", authors=["Nobody"]) fixture.add_book(3, "Metadata Only", authors=["Nobody"])
return fixture.commit() return fixture.commit()
@@ -95,7 +97,8 @@ async def test_an_uploaded_library_imports(
authenticated_client: AsyncClient, source: Path, tmp_path: Path authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None: ) -> None:
status, job = await upload( status, job = await upload(
authenticated_client, zip_of(source, tmp_path / "out", prefix="Calibre Library/") authenticated_client,
zip_of(source, tmp_path / "out", prefix="Calibre Library/"),
) )
assert status == 202 assert status == 202
@@ -278,7 +281,9 @@ async def test_a_second_copy_is_counted_as_a_possible_duplicate(
padded.write_bytes(EPUB.read_bytes() + b"\0" * 64) padded.write_bytes(EPUB.read_bytes() + b"\0" * 64)
fixture = CalibreFixture(tmp_path / "calibre") fixture = CalibreFixture(tmp_path / "calibre")
fixture.add_book(1, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": EPUB}) fixture.add_book(
1, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": EPUB}
)
fixture.add_book( fixture.add_book(
2, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": padded} 2, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": padded}
) )
+12 -3
View File
@@ -8,7 +8,9 @@ from pathlib import Path
# Known KOReader hashes for test files # Known KOReader hashes for test files
TEST_FILES = { TEST_FILES = {
"Moby Dick; Or, The Whale - Herman Melville.epub": { "Moby Dick; Or, The Whale - Herman Melville.epub": {
"path": Path("tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub"), "path": Path(
"tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub"
),
"hash": "ceeef909ec65653ba77e1380dff998fb", "hash": "ceeef909ec65653ba77e1380dff998fb",
"content_type": "application/epub+zip", "content_type": "application/epub+zip",
}, },
@@ -59,7 +61,9 @@ async def test_add_file_to_book_generates_correct_hash(
first_book = TEST_FILES["Moby Dick; Or, The Whale - Herman Melville.epub"] first_book = TEST_FILES["Moby Dick; Or, The Whale - Herman Melville.epub"]
first_content = first_book["path"].read_bytes() first_content = first_book["path"].read_bytes()
files = [("files", (first_book["path"].name, first_content, first_book["content_type"]))] files = [
("files", (first_book["path"].name, first_content, first_book["content_type"]))
]
data = {"library_id": "1"} data = {"library_id": "1"}
create_response = await authenticated_client.post( create_response = await authenticated_client.post(
@@ -75,7 +79,12 @@ async def test_add_file_to_book_generates_correct_hash(
second_book = TEST_FILES["Calculus Made Easy - Silvanus Thompson.pdf"] second_book = TEST_FILES["Calculus Made Easy - Silvanus Thompson.pdf"]
second_content = second_book["path"].read_bytes() second_content = second_book["path"].read_bytes()
add_files = [("data", (second_book["path"].name, second_content, second_book["content_type"]))] add_files = [
(
"data",
(second_book["path"].name, second_content, second_book["content_type"]),
)
]
add_response = await authenticated_client.post( add_response = await authenticated_client.post(
f"/books/{book_id}/files", f"/books/{book_id}/files",
+1 -1
View File
@@ -40,5 +40,5 @@ async def test_create_library(
assert result["name"] == "Test Library" assert result["name"] == "Test Library"
assert result["root_path"] == f"{tmp_path}/books" assert result["root_path"] == f"{tmp_path}/books"
assert result["path_template"] == "{author}/{title}" assert result["path_template"] == "{author}/{title}"
assert result["read_only"] == False assert result["read_only"] is False
assert result["description"] is None assert result["description"] is None
+22 -7
View File
@@ -2,7 +2,10 @@
from pathlib import Path from pathlib import Path
from chitai.services.filesystem_library import BookPathGenerator, sanitize_path_component from chitai.services.filesystem_library import (
BookPathGenerator,
sanitize_path_component,
)
ROOT = Path("/library") ROOT = Path("/library")
@@ -23,12 +26,15 @@ def test_a_book_with_no_authors() -> None:
def test_a_series_adds_a_level_and_pads_the_position() -> None: def test_a_series_adds_a_level_and_pads_the_position() -> None:
assert path_for( assert (
path_for(
title="Persepolis Rising", title="Persepolis Rising",
authors=["James S. A. Corey"], authors=["James S. A. Corey"],
series="The Expanse", series="The Expanse",
series_position="7", series_position="7",
) == ROOT / "James S. A. Corey" / "The Expanse" / "07 - Persepolis Rising" )
== ROOT / "James S. A. Corey" / "The Expanse" / "07 - Persepolis Rising"
)
def test_a_slash_in_a_title_does_not_add_a_directory() -> None: def test_a_slash_in_a_title_does_not_add_a_directory() -> None:
@@ -43,16 +49,25 @@ def test_a_slash_in_a_title_does_not_add_a_directory() -> None:
generated = path_for(title="Back in Black: AC/DC", authors=["Murray Engleheart"]) generated = path_for(title="Back in Black: AC/DC", authors=["Murray Engleheart"])
assert generated == ROOT / "Murray Engleheart" / "Back in Black: AC_DC" assert generated == ROOT / "Murray Engleheart" / "Back in Black: AC_DC"
assert generated.relative_to(ROOT).parts == ("Murray Engleheart", "Back in Black: AC_DC") assert generated.relative_to(ROOT).parts == (
"Murray Engleheart",
"Back in Black: AC_DC",
)
def test_a_slash_in_an_author_or_series_is_handled_too() -> None: def test_a_slash_in_an_author_or_series_is_handled_too() -> None:
assert path_for(title="Split", authors=["A/B Collective"]) == ( assert path_for(title="Split", authors=["A/B Collective"]) == (
ROOT / "A_B Collective" / "Split" ROOT / "A_B Collective" / "Split"
) )
assert path_for( assert (
title="Volume One", authors=["Someone"], series="Either/Or", series_position="1" path_for(
) == ROOT / "Someone" / "Either_Or" / "01 - Volume One" title="Volume One",
authors=["Someone"],
series="Either/Or",
series_position="1",
)
== ROOT / "Someone" / "Either_Or" / "01 - Volume One"
)
def test_control_characters_are_removed() -> None: def test_control_characters_are_removed() -> None:
+13 -5
View File
@@ -54,7 +54,8 @@ class TestNormalizeTitle:
assert normalize_title("The") == "the" assert normalize_title("The") == "the"
@pytest.mark.parametrize( @pytest.mark.parametrize(
"title", ["Catch 22", "Fahrenheit 451", "Blade Runner 2049", "1984", "Apollo 13"] "title",
["Catch 22", "Fahrenheit 451", "Blade Runner 2049", "1984", "Apollo 13"],
) )
def test_a_number_is_not_an_edition(self, title: str) -> None: def test_a_number_is_not_an_edition(self, title: str) -> None:
"""Edition stripping keys on the `e`; a bare number is part of the title.""" """Edition stripping keys on the `e`; a bare number is part of the title."""
@@ -163,8 +164,13 @@ class TestNormalizeIdentifier:
def test_uuids_are_refused(self) -> None: def test_uuids_are_refused(self) -> None:
"""Generated per build, so they only re-find what the hash check catches.""" """Generated per build, so they only re-find what the hash check catches."""
assert normalize_identifier("uuid", "3f2b1c4e-1111-2222-3333-444455556666") is None assert (
assert normalize_identifier("urn:uuid", "3f2b1c4e-1111-2222-3333-444455556666") is None normalize_identifier("uuid", "3f2b1c4e-1111-2222-3333-444455556666") is None
)
assert (
normalize_identifier("urn:uuid", "3f2b1c4e-1111-2222-3333-444455556666")
is None
)
def test_other_schemes_keep_their_own_key(self) -> None: def test_other_schemes_keep_their_own_key(self) -> None:
assert normalize_identifier("asin", "B000FC0PDA") == "asin:b000fc0pda" assert normalize_identifier("asin", "B000FC0PDA") == "asin:b000fc0pda"
@@ -187,7 +193,9 @@ class TestIsbnConversion:
assert isbn10_to_isbn13("043942089X") == "9780439420891" assert isbn10_to_isbn13("043942089X") == "9780439420891"
@pytest.mark.parametrize("isbn", ["0486282113", "9780486282114", "nonsense"]) @pytest.mark.parametrize("isbn", ["0486282113", "9780486282114", "nonsense"])
def test_anything_that_is_not_an_isbn_10_converts_to_nothing(self, isbn: str) -> None: def test_anything_that_is_not_an_isbn_10_converts_to_nothing(
self, isbn: str
) -> None:
assert isbn10_to_isbn13(isbn) is None assert isbn10_to_isbn13(isbn) is None
@@ -219,7 +227,7 @@ class TestParseIdentifier:
assert parse_identifier("B000FC0PDA", "mobi-asin") == ("asin", "B000FC0PDA") assert parse_identifier("B000FC0PDA", "mobi-asin") == ("asin", "B000FC0PDA")
def test_an_unrecognised_prefix_is_part_of_the_value(self) -> None: 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") == ( assert parse_identifier("http://www.gutenberg.org/5200") == (
"id", "id",
"http://www.gutenberg.org/5200", "http://www.gutenberg.org/5200",
+13 -5
View File
@@ -22,7 +22,6 @@ class TestEpubExtractor:
assert metadata["published_date"] == date(year=2001, month=7, day=1) assert metadata["published_date"] == date(year=2001, month=7, day=1)
EPUB = Path("tests/data_files/Metamorphosis - Franz Kafka.epub") EPUB = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
PDF = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf") PDF = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf")
@@ -31,7 +30,9 @@ PDF = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf")
class TestIdentifierMerging: class TestIdentifierMerging:
"""A book's formats each contribute identifiers; none of them replaces the rest.""" """A book's formats each contribute identifiers; none of them replaces the rest."""
async def test_every_format_contributes(self, monkeypatch: pytest.MonkeyPatch) -> None: async def test_every_format_contributes(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
""" """
Identifiers are a collection, not a single value. Identifiers are a collection, not a single value.
@@ -47,7 +48,9 @@ class TestIdentifierMerging:
} }
async def pdf(_file): async def pdf(_file):
return {"identifiers": {"isbn-13": "9781718500402", "isbn-10": "1593270356"}} return {
"identifiers": {"isbn-13": "9781718500402", "isbn-10": "1593270356"}
}
monkeypatch.setattr(EpubExtractor, "extract_metadata", epub) monkeypatch.setattr(EpubExtractor, "extract_metadata", epub)
monkeypatch.setattr(PdfExtractor, "extract_metadata", pdf) monkeypatch.setattr(PdfExtractor, "extract_metadata", pdf)
@@ -133,7 +136,9 @@ class TestSplitEdition:
), ),
], ],
) )
def test_editions_are_split_out(self, title: str, stripped: str, edition: int) -> None: def test_editions_are_split_out(
self, title: str, stripped: str, edition: int
) -> None:
assert split_edition(title) == (stripped, edition) assert split_edition(title) == (stripped, edition)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -171,7 +176,10 @@ class TestEditionFromFiles:
"""The PDF fixture calls itself a 2nd edition in its own metadata title.""" """The PDF fixture calls itself a 2nd edition in its own metadata title."""
metadata = await Extractor.extract_metadata([PDF]) metadata = await Extractor.extract_metadata([PDF])
assert metadata["title"] == "The Project Gutenberg eBook #33283: Calculus Made Easy" assert (
metadata["title"]
== "The Project Gutenberg eBook #33283: Calculus Made Easy"
)
assert metadata["edition"] == 2 assert metadata["edition"] == 2
@@ -25,7 +25,9 @@ PDF = DATA_FILES / "Calculus Made Easy - Silvanus Thompson.pdf"
def upload(path: Path, name: str | None = None) -> UploadFile: def upload(path: Path, name: str | None = None) -> UploadFile:
"""An uploaded file carrying the bytes of one of the test fixtures.""" """An uploaded file carrying the bytes of one of the test fixtures."""
return UploadFile( return UploadFile(
content_type="application/pdf" if path.suffix == ".pdf" else "application/epub+zip", content_type="application/pdf"
if path.suffix == ".pdf"
else "application/epub+zip",
filename=name or path.name, filename=name or path.name,
file_data=path.read_bytes(), file_data=path.read_bytes(),
) )
@@ -538,9 +540,7 @@ class TestBookPathCollisions:
assert original.path != forced.path assert original.path != forced.path
paths = { paths = {Path(book.path) / book.files[0].path for book in (original, forced)}
Path(book.path) / book.files[0].path for book in (original, forced)
}
assert len(paths) == 2 assert len(paths) == 2
assert all(path.is_file() for path in paths) assert all(path.is_file() for path in paths)
@@ -581,7 +581,10 @@ class TestBookPathCollisions:
# Renamed onto the first book's author and title. # Renamed onto the first book's author and title.
await books_service.update_book( await books_service.update_book(
second.books[0].id, second.books[0].id,
{"title": original.title, "authors": [author.name for author in original.authors]}, {
"title": original.title,
"authors": [author.name for author in original.authors],
},
test_library, test_library,
) )
@@ -743,7 +746,8 @@ class TestDuplicateBooks:
) )
matches = await books_service.find_duplicate_books( matches = await books_service.find_duplicate_books(
{"title": "Building Microservices", "authors": ["Newman, Sam;"]}, test_library {"title": "Building Microservices", "authors": ["Newman, Sam;"]},
test_library,
) )
assert [match.book_id for match in matches] == [stored.id] assert [match.book_id for match in matches] == [stored.id]
@@ -834,16 +838,22 @@ class TestDuplicateBooks:
"series": "Foundation", "series": "Foundation",
} }
assert await books_service.find_duplicate_books( assert (
await books_service.find_duplicate_books(
incoming | {"series_position": "2"}, test_library incoming | {"series_position": "2"}, test_library
) == [] )
== []
)
# The same volume, written a little differently, still matches. # The same volume, written a little differently, still matches.
assert len( assert (
len(
await books_service.find_duplicate_books( await books_service.find_duplicate_books(
incoming | {"series_position": "1.0"}, test_library incoming | {"series_position": "1.0"}, test_library
) )
) == 1 )
== 1
)
async def test_a_book_is_not_its_own_duplicate( async def test_a_book_is_not_its_own_duplicate(
self, books_service: BookService, test_library: m.Library self, books_service: BookService, test_library: m.Library
@@ -909,7 +919,10 @@ class TestAuthorNames:
existing row and then collides with it on the unique index. existing row and then collides with it on the unique index.
""" """
first = await store_book( first = await store_book(
books_service, test_library, title="Building Microservices", authors=["Sam Newman"] books_service,
test_library,
title="Building Microservices",
authors=["Sam Newman"],
) )
second = await store_book( second = await store_book(
books_service, books_service,
@@ -953,7 +966,9 @@ class TestAuthorNames:
"Franz Kafka.epub" is not a person. "Franz Kafka.epub" is not a person.
""" """
result = await books_service.create_many_from_files( result = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB, "Some Unknown Book - Ada Lovelace.epub")]), BooksCreateFromFiles(
files=[upload(EPUB, "Some Unknown Book - Ada Lovelace.epub")]
),
test_library, test_library,
) )
@@ -1014,7 +1029,10 @@ class TestMergeBooks:
) -> None: ) -> None:
"""The survivor keeps its own fields unless the caller says otherwise.""" """The survivor keeps its own fields unless the caller says otherwise."""
keep = await store_book( keep = await store_book(
books_service, test_library, title="Building Microservices", authors=["Sam Newman"] books_service,
test_library,
title="Building Microservices",
authors=["Sam Newman"],
) )
fold = await store_book( fold = await store_book(
books_service, books_service,
@@ -1030,7 +1048,11 @@ class TestMergeBooks:
assert merged.publisher is None assert merged.publisher is None
other = await store_book( other = await store_book(
books_service, test_library, title="Monolith", authors=["Sam Newman"], edition=3 books_service,
test_library,
title="Monolith",
authors=["Sam Newman"],
edition=3,
) )
merged = await books_service.merge_books( merged = await books_service.merge_books(
keep.id, [other.id], test_library, metadata={"edition": 3} keep.id, [other.id], test_library, metadata={"edition": 3}
@@ -1096,10 +1118,14 @@ class TestMergeBooks:
await books_service.merge_books(keep.id, [fold.id], test_library) await books_service.merge_books(keep.id, [fold.id], test_library)
rows = ( rows = (
(
await books_service.repository.session.execute( await books_service.repository.session.execute(
select(m.BookProgress).where(m.BookProgress.book_id == keep.id) select(m.BookProgress).where(m.BookProgress.book_id == keep.id)
) )
).scalars().all() )
.scalars()
.all()
)
assert [row.percentage for row in rows] == [0.6] assert [row.percentage for row in rows] == [0.6]
@@ -1112,13 +1138,23 @@ class TestMergeBooks:
) -> None: ) -> None:
"""Both books on one shelf must not leave the survivor linked to it twice.""" """Both books on one shelf must not leave the survivor linked to it twice."""
keep = await store_book( keep = await store_book(
books_service, test_library, title="A", authors=["X"], tags=["Shared", "Only Keep"] books_service,
test_library,
title="A",
authors=["X"],
tags=["Shared", "Only Keep"],
) )
fold = await store_book( fold = await store_book(
books_service, test_library, title="B", authors=["X"], tags=["Shared", "Only Fold"] books_service,
test_library,
title="B",
authors=["X"],
tags=["Shared", "Only Fold"],
) )
shelf = m.BookList(title="Later", user_id=test_user.id, library_id=test_library.id) shelf = m.BookList(
title="Later", user_id=test_user.id, library_id=test_library.id
)
session.add(shelf) session.add(shelf)
await session.commit() await session.commit()
session.add_all( session.add_all(
@@ -1131,13 +1167,21 @@ class TestMergeBooks:
merged = await books_service.merge_books(keep.id, [fold.id], test_library) merged = await books_service.merge_books(keep.id, [fold.id], test_library)
assert sorted(tag.name for tag in merged.tags) == ["Only Fold", "Only Keep", "Shared"] assert sorted(tag.name for tag in merged.tags) == [
"Only Fold",
"Only Keep",
"Shared",
]
links = ( links = (
(
await books_service.repository.session.execute( await books_service.repository.session.execute(
select(m.BookListLink).where(m.BookListLink.book_id == keep.id) select(m.BookListLink).where(m.BookListLink.book_id == keep.id)
) )
).scalars().all() )
.scalars()
.all()
)
assert len(links) == 1 assert len(links) == 1
async def test_an_identifier_moves_only_under_a_name_the_survivor_lacks( async def test_an_identifier_moves_only_under_a_name_the_survivor_lacks(
@@ -2,19 +2,12 @@
import pytest import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from chitai.services import ShelfService from chitai.services import ShelfService
from chitai.database import models as m from chitai.database import models as m
import pytest
from sqlalchemy import select
from chitai.services.bookshelf import ShelfService
from chitai.services import BookService
from chitai.database.models.book_list import BookList, BookListLink from chitai.database.models.book_list import BookList, BookListLink
from chitai.database import models as m
@pytest.fixture @pytest.fixture
@@ -201,7 +201,9 @@ async def test_importing_twice_creates_nothing(
] ]
held_by = [ held_by = [
skipped.book_id for skipped in result.skipped if skipped.reason == "already stored" skipped.book_id
for skipped in result.skipped
if skipped.reason == "already stored"
] ]
assert all(book_id is not None for book_id in held_by) assert all(book_id is not None for book_id in held_by)
@@ -223,7 +225,9 @@ async def test_a_file_the_catalogue_lists_but_disk_does_not(
await source.close() await source.close()
assert len(result.created) == 1 assert len(result.created) == 1
assert [(s.calibre_id, s.reason) for s in result.skipped] == [(2, "no files on disk")] assert [(s.calibre_id, s.reason) for s in result.skipped] == [
(2, "no files on disk")
]
async def test_one_broken_book_does_not_stop_the_import( async def test_one_broken_book_does_not_stop_the_import(
@@ -312,7 +316,11 @@ async def test_shared_authors_and_tags_are_one_row_each(
1, "One", authors=["Franz Kafka"], tags=["Fiction"], formats={"EPUB": EPUB} 1, "One", authors=["Franz Kafka"], tags=["Fiction"], formats={"EPUB": EPUB}
) )
fixture.add_book( fixture.add_book(
2, "Two", authors=["Franz Kafka"], tags=["Fiction"], formats={"EPUB": OTHER_EPUB} 2,
"Two",
authors=["Franz Kafka"],
tags=["Fiction"],
formats={"EPUB": OTHER_EPUB},
) )
root = fixture.commit() root = fixture.commit()
@@ -34,8 +34,8 @@ class TestLibraryServiceCRUD:
assert library.name == "Test Library" assert library.name == "Test Library"
assert library.root_path == library_path assert library.root_path == library_path
assert library.path_template == "{author}/{title}" assert library.path_template == "{author}/{title}"
assert library.description == None assert library.description is None
assert library.read_only == False assert library.read_only is False
# Check if directory was created # Check if directory was created
assert Path(library.root_path).is_dir() assert Path(library.root_path).is_dir()
@@ -56,8 +56,8 @@ class TestLibraryServiceCRUD:
read_only=False, read_only=False,
) )
with pytest.raises(PermissionError) as exc_info: with pytest.raises(PermissionError):
library = await library_service.create(library_data) await library_service.create(library_data)
# Check if directory was created # Check if directory was created
assert not Path(library_path).exists() assert not Path(library_path).exists()
@@ -86,8 +86,8 @@ class TestLibraryServiceCRUD:
assert library.name == "Test Library" assert library.name == "Test Library"
assert library.root_path == library_path assert library.root_path == library_path
assert library.path_template == "{author}/{title}" assert library.path_template == "{author}/{title}"
assert library.description == None assert library.description is None
assert library.read_only == True assert library.read_only is True
async def test_create_library_read_only_nonexistent_path( async def test_create_library_read_only_nonexistent_path(
self, library_service: LibraryService, tmp_path: Path self, library_service: LibraryService, tmp_path: Path
@@ -138,7 +138,7 @@ class TestLibraryServiceCRUD:
assert library.root_path == "./books" assert library.root_path == "./books"
assert library.path_template == "{author}/{title}" assert library.path_template == "{author}/{title}"
assert library.description is None assert library.description is None
assert library.read_only == False assert library.read_only is False
# async def test_delete_library_keep_files( # async def test_delete_library_keep_files(
# self, session: AsyncSession, library_service: LibraryService # self, session: AsyncSession, library_service: LibraryService
@@ -21,7 +21,7 @@ class TestUserServiceAuthentication:
# Create a user with a known password # Create a user with a known password
password = "password123" password = "password123"
user = m.User(email=f"test@example.com", password=password) user = m.User(email="test@example.com", password=password)
session.add(user) session.add(user)
await session.commit() await session.commit()
@@ -52,7 +52,7 @@ class TestUserServiceAuthentication:
# Create user # Create user
password = "password123" password = "password123"
user = m.User(email=f"test@example.com", password=password) user = m.User(email="test@example.com", password=password)
session.add(user) session.add(user)
await session.commit() await session.commit()
@@ -85,7 +85,7 @@ class TestUserServiceCRUD:
) -> None: ) -> None:
"""Test getting user by email.""" """Test getting user by email."""
user = m.User(email=f"test@example.com", password="password123") user = m.User(email="test@example.com", password="password123")
session.add(user) session.add(user)
await session.commit() await session.commit()
@@ -102,12 +102,12 @@ class TestUserServiceCRUD:
"""Test creating a new user with a duplicate email.""" """Test creating a new user with a duplicate email."""
# Create first user # Create first user
user = m.User(email=f"test@example.com", password="password123") user = m.User(email="test@example.com", password="password123")
session.add(user) session.add(user)
await session.commit() await session.commit()
# Create second user # Create second user
user = m.User(email=f"test@example.com", password="password12345") user = m.User(email="test@example.com", password="password12345")
with pytest.raises(IntegrityError) as exc_info: with pytest.raises(IntegrityError) as exc_info:
session.add(user) session.add(user)
+27
View File
@@ -261,6 +261,7 @@ dev = [
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "pytest-databases", extra = ["postgres"] }, { name = "pytest-databases", extra = ["postgres"] },
{ name = "ruff" },
] ]
[package.metadata] [package.metadata]
@@ -286,6 +287,7 @@ dev = [
{ name = "pytest", specifier = ">=8.4.2" }, { name = "pytest", specifier = ">=8.4.2" },
{ name = "pytest-asyncio", specifier = ">=1.2.0" }, { name = "pytest-asyncio", specifier = ">=1.2.0" },
{ name = "pytest-databases", extras = ["postgres"], specifier = ">=0.15.0" }, { name = "pytest-databases", extras = ["postgres"], specifier = ">=0.15.0" },
{ name = "ruff", specifier = "==0.15.14" },
] ]
[[package]] [[package]]
@@ -1277,6 +1279,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/e5/d708d262b600a352abe01c2ae360d8ff75b0af819b78e9af293191d928e6/rich_click-1.9.7-py3-none-any.whl", hash = "sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b", size = 71491, upload-time = "2026-01-31T04:29:26.777Z" }, { url = "https://files.pythonhosted.org/packages/ca/e5/d708d262b600a352abe01c2ae360d8ff75b0af819b78e9af293191d928e6/rich_click-1.9.7-py3-none-any.whl", hash = "sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b", size = 71491, upload-time = "2026-01-31T04:29:26.777Z" },
] ]
[[package]]
name = "ruff"
version = "0.15.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" },
{ url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" },
{ url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" },
{ url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" },
{ url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" },
{ url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" },
{ url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" },
{ url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" },
{ url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" },
{ url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" },
{ url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" },
{ url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" },
{ url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" },
{ url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" },
{ url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" },
]
[[package]] [[package]]
name = "six" name = "six"
version = "1.17.0" version = "1.17.0"
+9
View File
@@ -24,6 +24,10 @@ services:
backend: backend:
image: git.jaroszew.ski/patrick/chitai-backend:${CHITAI_VERSION:-latest}
# Only used when the image above is not present locally. Run `docker compose build` to
# build from source instead of pulling a release.
build: ./backend build: ./backend
networks: networks:
@@ -53,6 +57,8 @@ services:
condition: service_healthy condition: service_healthy
frontend: frontend:
image: git.jaroszew.ski/patrick/chitai-frontend:${CHITAI_VERSION:-latest}
build: ./frontend build: ./frontend
networks: networks:
@@ -66,6 +72,9 @@ services:
environment: environment:
VITE_BACKEND_API_URL: ${CHITAI_API_URL} VITE_BACKEND_API_URL: ${CHITAI_API_URL}
# Must match the URL the browser uses, or SvelteKit rejects form POSTs as cross-origin.
ORIGIN: ${CHITAI_ORIGIN:-http://localhost:3000}
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
+302
View File
@@ -0,0 +1,302 @@
# Building release images in CI
Design for publishing `chitai-backend` and `chitai-frontend` container images from a tagged
release, on Gitea Actions, to the Gitea package registry, for `linux/amd64`.
## What exists today
- `origin` is Gitea 1.27 at `git.jaroszew.ski`. The repo is **public**, with `has_actions` and
`has_packages` both true. There is no `.gitea/` or `.github/` directory and no CI of any kind.
- There are **no tags** in the repository. `backend/pyproject.toml` says `0.1.0`,
`frontend/package.json` says `0.0.1`; nothing reads either.
- `backend/Dockerfile` and `frontend/Dockerfile` are both multi-stage and both already build a
runnable image. `docker-compose.yml` builds them from source with `build: ./backend` and
`build: ./frontend` — there is no `image:` key, so there is nothing for a user to pull.
So the work is not "make the images build" — they build. It is "make an image built on one machine
correct on another", then automate producing one per tag.
## The blocker: the frontend image hardcodes the backend URL
This has to be fixed before publishing an image is meaningful.
`frontend/src/lib/server/config.ts` reads the backend URL through `import.meta.env`:
```ts
export const BACKEND_API_URL = import.meta.env.VITE_BACKEND_API_URL || 'http://localhost:8000';
```
Vite replaces `import.meta.env.VITE_*` **at build time**, including in the SSR bundle. The current
build output in `frontend/build/` shows exactly what that produces:
```js
// frontend/build/server/chunks/config-BvKh7uym.js
const BACKEND_API_URL = "http://localhost:8000";
```
`frontend/Dockerfile` sets no `VITE_BACKEND_API_URL` before `pnpm run build`, so the string baked
into any image built from it is `http://localhost:8000`. The `VITE_BACKEND_API_URL: ${CHITAI_API_URL}`
entry under the compose `frontend` service is therefore **dead** — it sets a process environment
variable that nothing reads, in a process whose value was decided at build time. Inside the
container `localhost:8000` is the frontend's own port, not the backend.
Two ways out, and only one of them is right for a published image:
- **Build arg.** `ARG VITE_BACKEND_API_URL` in the build stage. This works, but it makes the image
specific to one deployment's topology — CI would bake `http://backend:8000` and anyone whose
service is named differently gets an image that cannot be repointed. Wrong for a release artifact.
- **Runtime env, recommended.** Read it through SvelteKit's dynamic env, which is `process.env` at
request time:
```ts
import { env } from '$env/dynamic/private';
export const BACKEND_API_URL = env.VITE_BACKEND_API_URL || 'http://localhost:8000';
```
`$lib/server/config.ts` is server-only and its one consumer (`$lib/server/api.ts`) is too, so
`$env/dynamic/private` is available everywhere it is used. The compose entry then starts working
as written, and the same image serves any deployment.
Worth renaming the variable to `CHITAI_API_URL` at the same time — the `VITE_` prefix now means
the opposite of what it does — but that is a follow-up, not a prerequisite. If you do rename it,
the fallback in `.env.prod-example` and the compose `environment:` block move with it.
Same class of problem, same fix window: `frontend/Dockerfile` sets `ENV ORIGIN=http://localhost:3000`.
That one *is* read at runtime by `adapter-node`, so it can be overridden — but nothing overrode it,
and adapter-node rejects cross-origin form POSTs when `ORIGIN` does not match the browser's, so
every deployment behind a real domain 403s on its first login. Now set as
`ORIGIN: ${CHITAI_ORIGIN:-http://localhost:3000}` on the compose `frontend` service, with a
documented `CHITAI_ORIGIN` in `.env.prod-example`.
**Both are done.** The built module now reads `private_env.VITE_BACKEND_API_URL`, and the published
image was verified by running it with `VITE_BACKEND_API_URL` pointed at a throwaway listener: the
container's `GET /access/me` arrived there rather than at `localhost:8000`.
## Toolchain pinning
Two reproducibility gaps that a release pipeline turns from cosmetic into real, because CI builds
from a clean container every time and your laptop does not.
- **pnpm has no pin.** `frontend/package.json` has no `packageManager` field, so `corepack enable`
followed by `pnpm install` resolves to whatever version corepack considers current on the day the
build runs. `pnpm-lock.yaml` is `lockfileVersion: 9.0`, i.e. pnpm 9/10; a future pnpm 11 could
refuse it, and `--frozen-lockfile` would fail a release for reasons unrelated to the release. Add
`"packageManager": "pnpm@<version from your nix shell>"` and let corepack honour it.
- **`pnpm-workspace.yaml` is never copied into the image.** It carries `onlyBuiltDependencies`
(`esbuild`, `@tailwindcss/oxide`), and pnpm 10 blocks postinstall scripts that are not listed
there. Both install stages in `frontend/Dockerfile` copy only `pnpm-lock.yaml` and `package.json`,
so the container install runs under different rules than the local one. Copy it alongside
`package.json` in the `prod-deps` and `build` stages so the two agree.
Both are one-line changes and both belong before the first tag, not after. **Both are done**
`packageManager` is pinned to the dev shell's `pnpm@11.20.0`, and `pnpm-workspace.yaml` is copied
into the `prod-deps` and `build` stages. `docker compose build` was re-run against the change.
## Prerequisites on the Gitea instance
Verify these before writing the workflow; each one fails the job in a way that looks like a bug in
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, 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`,
`docker/login-action@v3`, `docker/metadata-action@v5` and `docker/build-push-action@v6` are all
mirrored on gitea.com at those tags (verified 2026-08-17). Worth knowing because it is where an
action reference resolves from if that setting is ever changed; `DEFAULT_ACTIONS_URL = github`
in `app.ini` is the fix if so. Writing full `https://` URLs in `uses:` also works on Gitea but
is invalid syntax on GitHub Actions, so it would cost portability for no gain.
4. **A registry credential.** Gitea auto-injects `secrets.GITEA_TOKEN`, but whether it carries
package-write scope has varied across versions. Try it first; if the push 401s, create a personal
access token with `write:package` and store it as the repo secret `REGISTRY_TOKEN`. The workflow
below reads `REGISTRY_TOKEN` with a fallback to the automatic token.
5. **Package visibility.** Gitea ties package visibility to the owner rather than offering a
per-package toggle. The repo is public, so anonymous pulls should work — confirm with a
`docker pull` from a logged-out machine after the first release, because the README will tell
people to do exactly that.
## The workflow
Written as **`.gitea/workflows/release.yml`**, triggered by tags matching `v*`. Notes on the choices
made there:
- **`github.*` context, not `gitea.*`.** Both exist on Gitea; the third-party actions read the
`GITHUB_*` environment anyway, and using it keeps the file portable if the repo is ever mirrored.
`github.repository` is `patrick/chitai`, so the images are
`git.jaroszew.ski/patrick/chitai-backend` and `…/chitai-frontend`.
- **Tags produced from `v1.2.3`:** `1.2.3`, `1.2`, `1`, and `latest`. `metadata-action`'s default
`latest=auto` flavour adds `latest` only for a non-prerelease semver, so `v0.2.0-rc.1` publishes
`0.2.0-rc.1` and leaves `latest` where it was. That is what makes release-candidate tags a safe
way to exercise the pipeline.
- **`fail-fast: false`** so a frontend failure does not cancel a backend build that was going to
succeed. The two images are independent artifacts; a half-published release is easier to reason
about than a cancelled one.
- **`type=gha` cache** relies on `act_runner`'s built-in cache server exporting
`ACTIONS_CACHE_URL` / `ACTIONS_RUNTIME_TOKEN`. If your runner has caching disabled, buildx warns
and continues, or errors depending on version — just delete the two `cache-*` lines. The
Dockerfiles' `--mount=type=cache` blocks do nothing across ephemeral runners either way, and a
cold build of both images is a few minutes.
## Gating: formatting, linting, types and tests
`ci.yml` runs these on every push and pull request, and `release.yml`'s `quality` job runs the
blocking half again on the tagged commit, so a release cannot publish an image whose tests fail.
My first draft of this document argued for keeping all of it out of the release path, on the
grounds that everything was red. Measuring rather than trusting `TODO.md` showed that was mostly
wrong:
| Check | Before | After the sweep | Gate |
| --- | --- | --- | --- |
| `pytest tests/` | **411 passed** in 3 min | unchanged | blocking |
| `ruff format --check src/` | 27 of 66 files | clean | blocking |
| `ruff check src/` | 110 errors | clean | blocking |
| `prettier --check .` | 48 files | clean | blocking |
| `eslint .` | 1804 → **87 real** | 87 | non-blocking |
| `pnpm check` | 30 errors | 30 | non-blocking |
The test suite was green the whole time, so gating on it costs nothing. `ruff check`'s 110 were
104 unused imports, and **all 64 that survived `--fix` were in `__init__.py`** — deliberate
re-exports, so the fix is `per-file-ignores` in `pyproject.toml`, not deleting them. Of eslint's
1804, **1717 were the vendored pdf.js under `static/`**, which the config never ignored the way it
ignores `src/lib/vendor/`; adding it leaves 87 real ones.
Two things the sweep turned up that are worth remembering:
- **ruff's suggested fix for `E712` would have broken the query.** `services/filters/book.py` had
`m.BookProgress.completed == True`, and ruff proposes `if m.BookProgress.completed:` — Python
truthiness on a SQLAlchemy Column, which does not generate SQL at all. The correct idiom is
`.is_(True)`, which the adjacent line already used. Never run `ruff check --fix --unsafe-fixes`
over query-building code without reading every hunk.
- **Prettier reformatted the generated `schema.d.ts`**, which was 3109 of the sweep's 3946 changed
lines. `openapi-typescript` writes its own style, so that file would have churned by thousands of
lines on every regeneration — and then failed the very gate being added. It is in
`.prettierignore` now.
`eslint` and `pnpm check` run with `continue-on-error: true`. That is an honest weak gate: it
reports without failing, so the counts stay visible and cannot grow silently unnoticed, but nobody
is blocked by 117 pre-existing problems they did not create. Drop the line from each step as its
count reaches zero.
The other thing worth gating on is that the images actually start, which is the failure mode a release
introduces and which nothing else catches. That is the `smoke` job in the same workflow: it copies
`.env.prod-example`, pins `CHITAI_VERSION` to the tag, `docker compose pull`s the images that were
just pushed and brings the stack up with `--wait`, then curls both healthchecks.
This is cheap and it covers the three things that break a release image: migrations failing to apply
from `entrypoint.sh`, the frontend being unable to reach the backend (the baked-URL bug above —
`--wait` fails because the frontend never goes healthy), and a missing runtime env var. It tests the
artifact that was pushed, not a rebuild of it.
`--wait` depends on healthchecks. `db` declares one inline in `docker-compose.yml`, and the backend
image carries a `HEALTHCHECK` in its Dockerfile which compose inherits. The frontend had neither, so
one was added to `frontend/Dockerfile` — in the image rather than in compose, matching the backend
and covering anyone running the image without compose. It probes `/login` with **node's global
`fetch`, not curl**, because `node:24-slim` ships no curl and adding one for a healthcheck is a
package and a CVE surface for nothing. It reads `PORT` so it keeps working if the port is
overridden.
Note the smoke job runs `cp .env.prod-example .env`, which is destructive on a developer machine —
it is safe only because a CI checkout has no `.env`. Don't run those lines locally.
## Making the images consumable
The images are pointless if `docker-compose.yml` still builds from source. Both services now carry
`image:` **and** keep `build:` — compose pulls when the image is absent, and `docker compose build`
still builds from source and tags the result under the same name:
```yaml
backend:
image: git.jaroszew.ski/patrick/chitai-backend:${CHITAI_VERSION:-latest}
build: ./backend
```
`CHITAI_VERSION` and `CHITAI_ORIGIN` are documented in `.env.prod-example`, and the README's install
steps now copy and edit `.env` *before* pulling — the pull cannot resolve `${CHITAI_VERSION}` until
that file exists, so the old ordering would have silently fetched `latest`.
## Versioning
Adopt `vMAJOR.MINOR.PATCH`. The tag is the source of truth; `metadata-action` derives everything
from it and the OCI labels record it. There is no tag today, so the first release is a decision —
`v0.1.0` matches `backend/pyproject.toml` and is the honest number for an app with an open
"any authenticated user can delete any library" item.
Do not automate bumping `pyproject.toml` and `package.json` from the tag. It requires CI to commit
back to the repo, and neither version is read by anything. Either keep a two-line manual checklist
(bump both, commit, tag) or delete `frontend/package.json`'s version field and treat the backend's
as the product version. Bumping by hand is the smaller cost.
## Order of work
- [x] **Runtime config.** `frontend/src/lib/server/config.ts``$env/dynamic/private`; `ORIGIN` on
the compose frontend service; `CHITAI_ORIGIN` in `.env.prod-example`.
- [x] **Toolchain pins.** `packageManager` in `frontend/package.json`; `pnpm-workspace.yaml` copied
in both `frontend/Dockerfile` install stages.
- [x] **The workflow.** `.gitea/workflows/release.yml`, both jobs.
- [x] **Consumable images.** `image:` keys in `docker-compose.yml`, frontend `HEALTHCHECK`,
`CHITAI_VERSION`, README install steps.
- [ ] **Stand up `act_runner` with a working Docker daemon.** Not something the repo can carry.
Prove it with a throwaway workflow running `docker version` before trusting the release one.
- [ ] **Tag `v0.1.0-rc.1`.** Confirm two images land in the registry, that `latest` was *not* moved,
and that the smoke job goes green.
- [ ] **Confirm an anonymous `docker pull`** from a logged-out machine, since the README tells people
to do exactly that.
- [ ] **Tag `v0.1.0`.**
Everything the repository can hold is in place; what remains is instance-side and needs a real tag
to exercise. Verified locally along the way: `docker compose build` succeeds against both changed
Dockerfiles, `docker compose config` resolves the image names, and the built frontend image
honours `VITE_BACKEND_API_URL` at runtime and reports `healthy` to Docker.
## Deferred, deliberately
- **Multi-arch.** amd64 only for now. Adding `linux/arm64` under QEMU roughly triples the job and the
Python and pnpm installs are exactly the workload emulation is worst at; a native arm runner and a
fan-out/merge workflow is the answer if it is ever needed.
- **Building on `main`.** An `edge` tag from every push to `main` is a two-line addition to the same
workflow (`on: push: branches: [main]` plus `type=raw,value=edge,enable={{is_default_branch}}`).
Left out because it doubles registry churn for a deployment target that does not exist yet.
- **PR CI.** Formatting, lint, type and test gates are a separate workflow on a separate trigger and
should not be mixed into the release path — see the gating section.
- **Gitea Releases.** A job creating a release object with generated notes is nice-to-have and can
be added once the tags mean something.
- **Signing / SBOM / provenance.** `build-push-action` can emit provenance and an SBOM, and cosign
can sign the digests. Worth it if the images are ever consumed by anyone other than you; not worth
the key management before then.
- **`backend/.dockerignore` is thin.** It excludes `.git`, `__pycache__` and the ruff cache, but not
`.venv/`, `.postgres/`, `libraries/`, `covers/` or `tests/`. None of those reach the image — the
Dockerfile copies specific paths — and none exist in a CI checkout since they are gitignored, so
this does not affect the pipeline. It does make local builds slower than they need to be.
+5
View File
@@ -8,9 +8,14 @@ bun.lockb
# Ignore artifacts: # Ignore artifacts:
build build
coverage coverage
.pytest_cache
# Miscellaneous # Miscellaneous
/static/ /static/
# Vendored third-party source, copied verbatim by scripts/vendor-foliate.sh # Vendored third-party source, copied verbatim by scripts/vendor-foliate.sh
/src/lib/vendor/ /src/lib/vendor/
# Generated by openapi-typescript, which has its own formatting. Reformatting it here would
# make every regeneration a several-thousand-line diff.
/src/lib/schema/openapi/schema.d.ts
+14 -7
View File
@@ -157,15 +157,22 @@ but take a baseline first, because neither is clean (see below).
Observed in the current tree — don't mistake these for intentional patterns to copy: Observed in the current tree — don't mistake these for intentional patterns to copy:
- `pnpm check` is not clean. **Baseline as of 2026-08-13: 30 errors, 1 warning, 8 files**, most of - **`pnpm check` is clean as of 2026-08-17 and CI blocks on it** — 0 errors, 0 warnings. Any error
them in `src/routes/api/[...path]/+server.ts` (see below). Get your own baseline before assuming you see is yours. `src/lib/schema/openapi/schema.d.ts` is
an error is yours. `src/lib/schema/openapi/schema.d.ts` was regenerated on that date and is current; regenerate it after any backend API change, with
current; regenerate it again after any backend API change, with
`pnpm exec openapi-typescript http://localhost:8000/schema/openapi.json -o src/lib/schema/openapi/schema.d.ts` `pnpm exec openapi-typescript http://localhost:8000/schema/openapi.json -o src/lib/schema/openapi/schema.d.ts`
against a backend running **your** branch — a stale server silently writes a stale file. against a backend running **your** branch — a stale server silently writes a stale file.
- `pnpm lint` does not pass either — 131 pre-existing ESLint errors, 35 of them - **Prettier is clean and CI blocks on it** — run `pnpm format` before finishing. Two things it
`svelte/no-navigation-without-resolve` on plain `href`s, plus ~59 files Prettier would rewrite must not touch are in `.prettierignore`: the vendored foliate-js, and
(mostly vendored shadcn components). Check the files you touched, not the whole tree. `src/lib/schema/openapi/schema.d.ts`, which `openapi-typescript` regenerates in its own style.
- `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 - `src/routes/api/[...path]/+server.ts` — all four handlers are annotated `RequestHandler` while the
import of that type is commented out at line 4. It also buffers whole **responses** with import of that type is commented out at line 4. It also buffers whole **responses** with
`arrayBuffer()` and forwards no `Range` header, so book downloads are not streamed. **Requests** `arrayBuffer()` and forwards no `Range` header, so book downloads are not streamed. **Requests**
+7 -2
View File
@@ -11,7 +11,9 @@ WORKDIR /app
FROM base AS prod-deps FROM base AS prod-deps
COPY pnpm-lock.yaml ./ # pnpm-workspace.yaml carries onlyBuiltDependencies; without it pnpm blocks the postinstall
# scripts it lists, so the install here would not match a local one.
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
RUN --mount=type=cache,target=/pnpm/store \ RUN --mount=type=cache,target=/pnpm/store \
pnpm fetch --frozen-lockfile pnpm fetch --frozen-lockfile
@@ -25,7 +27,7 @@ RUN --mount=type=cache,target=/pnpm/store \
FROM base AS build FROM base AS build
COPY pnpm-lock.yaml package.json ./ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
RUN --mount=type=cache,target=/pnpm/store \ RUN --mount=type=cache,target=/pnpm/store \
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
@@ -54,4 +56,7 @@ EXPOSE 3000
WORKDIR /app WORKDIR /app
HEALTHCHECK --interval=10s --timeout=3s --retries=5 --start-period=10s \
CMD ["node", "-e", "fetch(`http://127.0.0.1:${process.env.PORT || 3000}/login`).then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
CMD [ "node", "build" ] CMD [ "node", "build" ]
+29 -2
View File
@@ -13,7 +13,9 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig( export default defineConfig(
includeIgnoreFile(gitignorePath), includeIgnoreFile(gitignorePath),
// Vendored third-party source. Tracked, so .gitignore does not cover it. // Vendored third-party source. Tracked, so .gitignore does not cover it.
{ ignores: ['src/lib/vendor/**'] }, // static/pdfjs is the pdf.js viewer, vendored the same way as src/lib/vendor/foliate-js;
// linting it produced 1717 of the 1804 errors this config used to report.
{ ignores: ['src/lib/vendor/**', 'static/pdfjs/**'] },
js.configs.recommended, js.configs.recommended,
...ts.configs.recommended, ...ts.configs.recommended,
...svelte.configs.recommended, ...svelte.configs.recommended,
@@ -26,7 +28,18 @@ export default defineConfig(
rules: { rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off' 'no-undef': 'off',
// A leading underscore marks a binding that exists to hold a position — a callback
// parameter the signature requires, or the discarded half of a destructure.
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_'
}
]
} }
}, },
{ {
@@ -35,6 +48,20 @@ export default defineConfig(
files: ['src/lib/components/ui/**'], files: ['src/lib/components/ui/**'],
rules: { 'svelte/no-navigation-without-resolve': 'off' } rules: { 'svelte/no-navigation-without-resolve': 'off' }
}, },
{
// 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',
'@typescript-eslint/no-unused-expressions': 'off'
}
},
{ {
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: { languageOptions: {
+28 -27
View File
@@ -2,6 +2,7 @@
"name": "chitai-web", "name": "chitai-web",
"private": true, "private": true,
"version": "0.0.1", "version": "0.0.1",
"packageManager": "pnpm@11.20.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -14,42 +15,42 @@
"lint": "prettier --check . && eslint ." "lint": "prettier --check . && eslint ."
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.4.1", "@eslint/compat": "^2.1.0",
"@eslint/js": "^9.39.4", "@eslint/js": "^10.0.1",
"@iconify/svelte": "^5.2.1", "@iconify/svelte": "^5.2.2",
"@internationalized/date": "^3.12.0", "@internationalized/date": "^3.12.3",
"@lucide/svelte": "^0.544.0", "@lucide/svelte": "^1.31.0",
"@sveltejs/adapter-node": "^5.5.4", "@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.53.4", "@sveltejs/kit": "^2.70.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^7.3.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.3.3",
"@types/node": "^22.19.15", "@types/node": "^26.2.0",
"bits-ui": "^2.16.3", "bits-ui": "^2.18.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"eslint": "^9.39.4", "eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.15.0", "eslint-plugin-svelte": "^3.23.0",
"globals": "^16.5.0", "globals": "^17.11.0",
"jsrepo": "^2.5.2", "jsrepo": "^3.8.1",
"openapi-typescript": "^7.13.0", "openapi-typescript": "^7.13.0",
"prettier": "^3.8.1", "prettier": "^3.9.6",
"prettier-plugin-svelte": "^3.5.2", "prettier-plugin-svelte": "^4.1.1",
"prettier-plugin-tailwindcss": "^0.8.1", "prettier-plugin-tailwindcss": "^0.8.1",
"svelte": "^5.53.7", "svelte": "^5.56.9",
"svelte-check": "^4.4.5", "svelte-check": "^4.7.6",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.6.0",
"tailwind-scrollbar": "^4.0.2", "tailwind-scrollbar": "^4.0.2",
"tailwind-variants": "^3.2.2", "tailwind-variants": "^3.3.1",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.56.1", "typescript-eslint": "^8.67.0",
"vite": "^7.3.1" "vite": "^8.2.1"
}, },
"dependencies": { "dependencies": {
"construct-style-sheets-polyfill": "^3.1.0", "construct-style-sheets-polyfill": "^3.1.0",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"svelte-sonner": "^1.0.8", "svelte-sonner": "^1.2.1",
"zod": "^4.3.6" "zod": "^4.4.3"
} }
} }
+1592 -2033
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -10,7 +10,8 @@
--radius: 0.625rem; --radius: 0.625rem;
/* Typography — system stacks, so nothing depends on a CDN or a webfont build. */ /* Typography — system stacks, so nothing depends on a CDN or a webfont build. */
--app-font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; --app-font-sans:
system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--app-font-serif: Georgia, 'Iowan Old Style', 'Times New Roman', serif; --app-font-serif: Georgia, 'Iowan Old Style', 'Times New Roman', serif;
--app-font-mono: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace; --app-font-mono: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace;
+1 -1
View File
@@ -4,7 +4,7 @@ import { BACKEND_API_URL } from '$lib/server/config';
import { invalid, redirect } from '@sveltejs/kit'; import { invalid, redirect } from '@sveltejs/kit';
export const login = form(loginSchema, async (data, issue) => { export const login = form(loginSchema, async (data, issue) => {
const { cookies, locals } = getRequestEvent(); const { cookies } = getRequestEvent();
// Create URL-encoded form data // Create URL-encoded form data
const formData = new URLSearchParams(); const formData = new URLSearchParams();
+21 -11
View File
@@ -1,5 +1,10 @@
import { command, getRequestEvent, query } from '$app/server'; import { command, getRequestEvent, query } from '$app/server';
import { bookshelfCreate, bookshelfQuerySchema, modifyBooksInShelf, type Bookshelf } from '$lib/schema/bookshelf'; import {
bookshelfCreate,
bookshelfQuerySchema,
modifyBooksInShelf,
type Bookshelf
} from '$lib/schema/bookshelf';
import { createQueryParams } from '$lib/utils'; import { createQueryParams } from '$lib/utils';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
@@ -19,7 +24,9 @@ export const listBookshelves = query(bookshelfQuerySchema, async (data) => {
return await response.json(); return await response.json();
}); });
export const addBooksToShelf = command(modifyBooksInShelf, async ({ shelf_id, ...data }): Promise<Bookshelf> => { export const addBooksToShelf = command(
modifyBooksInShelf,
async ({ shelf_id, ...data }): Promise<Bookshelf> => {
const { locals } = getRequestEvent(); const { locals } = getRequestEvent();
const params = createQueryParams(data); const params = createQueryParams(data);
@@ -31,10 +38,13 @@ export const addBooksToShelf = command(modifyBooksInShelf, async ({ shelf_id, ..
error(response.status, message); error(response.status, message);
} }
return await response.json() return await response.json();
}); }
);
export const removeBooksFromShelf = command(modifyBooksInShelf, async ({ shelf_id, ...data }): Promise<Bookshelf> => { export const removeBooksFromShelf = command(
modifyBooksInShelf,
async ({ shelf_id, ...data }): Promise<Bookshelf> => {
const { locals } = getRequestEvent(); const { locals } = getRequestEvent();
const params = createQueryParams(data); const params = createQueryParams(data);
@@ -46,19 +56,19 @@ export const removeBooksFromShelf = command(modifyBooksInShelf, async ({ shelf_i
error(response.status, message); error(response.status, message);
} }
return await response.json() return await response.json();
}); }
);
export const createBookshelf = command(bookshelfCreate, async (data) => { export const createBookshelf = command(bookshelfCreate, async (data) => {
const { locals } = getRequestEvent(); const { locals } = getRequestEvent();
const response = await locals.api.post(`/shelves`, data) const response = await locals.api.post(`/shelves`, data);
if (!response.ok) { if (!response.ok) {
const message = await response.text(); const message = await response.text();
error(response.status, message); error(response.status, message);
} }
return await response.json() return await response.json();
}) });
+2 -2
View File
@@ -11,7 +11,7 @@ export const listDevices = query(async (): Promise<Device[]> => {
if (!response.ok) error(500, 'An unkown error occurred'); if (!response.ok) error(500, 'An unkown error occurred');
const deviceResult = await response.json(); const deviceResult = await response.json();
return deviceResult.items return deviceResult.items;
}); });
export const createDevice = form(createDeviceSchema, async (data): Promise<Device> => { export const createDevice = form(createDeviceSchema, async (data): Promise<Device> => {
@@ -32,7 +32,7 @@ export const regenerateDeviceApiKey = command(z.string(), async (deviceId): Prom
if (!response.ok) error(500, 'An unknown error occurred'); if (!response.ok) error(500, 'An unknown error occurred');
return await response.json(); return await response.json();
}) });
export const deleteDevice = command(z.string(), async (deviceId): Promise<void> => { export const deleteDevice = command(z.string(), async (deviceId): Promise<void> => {
const { locals } = getRequestEvent(); const { locals } = getRequestEvent();
+1 -1
View File
@@ -25,6 +25,6 @@ export const createLibrary = form(libraryCreateSchema, async (data) => {
return await response.json(); return await response.json();
}); });
export const deleteLibrary = query('unchecked', async (data) => { export const deleteLibrary = query('unchecked', async (_data) => {
throw new Error('Not implemented'); throw new Error('Not implemented');
}); });
@@ -3,8 +3,6 @@
import { Checkbox } from '$lib/components/ui/checkbox/index.js'; import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js';
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
let { let {
open = $bindable(), open = $bindable(),
@@ -13,12 +11,9 @@
}: { }: {
open: boolean; open: boolean;
title?: string; title?: string;
deleteFn: (deleteFiles: boolean) => {}; deleteFn: (deleteFiles: boolean) => void | Promise<void>;
} = $props(); } = $props();
const selectedState = getBookSelectionState();
const bookOps = getBookOperationsState();
let deleteFiles = $state(false); let deleteFiles = $state(false);
</script> </script>
@@ -13,7 +13,7 @@
</script> </script>
<form <form
{...createDevice.enhance(async ({ form, submit }) => { {...createDevice.enhance(async ({ element, submit }) => {
try { try {
await submit(); await submit();
@@ -24,7 +24,7 @@
const deviceName = createDevice.fields.name.value(); const deviceName = createDevice.fields.name.value();
form.reset(); element.reset();
toast.success(`Device '${deviceName}' created`); toast.success(`Device '${deviceName}' created`);
onSuccess?.(); onSuccess?.();
} catch (error) { } catch (error) {
@@ -39,7 +39,7 @@
<Field.Field> <Field.Field>
<Field.Label for="name">Device name</Field.Label> <Field.Label for="name">Device name</Field.Label>
<Input {...createDevice.fields.name.as('text')} placeholder="e.g. Kindle Paperwhite" /> <Input {...createDevice.fields.name.as('text')} placeholder="e.g. Kindle Paperwhite" />
{#each createDevice.fields.name.issues() ?? [] as issue} {#each createDevice.fields.name.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -50,10 +50,10 @@
<form <form
bind:this={formEl} bind:this={formEl}
{...updateBookCover.enhance(async ({ submit, form }) => { {...updateBookCover.enhance(async ({ submit, element }) => {
try { try {
await submit(); await submit();
form.reset(); element.reset();
// Deliberately does not close the dialog. The cover is one panel of a // Deliberately does not close the dialog. The cover is one panel of a
// larger form now, and closing here would throw away metadata edits // larger form now, and closing here would throw away metadata edits
// the reader has not saved yet. // the reader has not saved yet.
@@ -128,7 +128,7 @@
<Field.Field class="col-span-full"> <Field.Field class="col-span-full">
<Field.Label for="title">Title</Field.Label> <Field.Label for="title">Title</Field.Label>
<Input {...updateBookMetadata.fields.title.as('text')} /> <Input {...updateBookMetadata.fields.title.as('text')} />
{#each updateBookMetadata.fields.title.issues() ?? [] as issue} {#each updateBookMetadata.fields.title.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -136,7 +136,7 @@
<Field.Field> <Field.Field>
<Field.Label for="subtitle">Subtitle</Field.Label> <Field.Label for="subtitle">Subtitle</Field.Label>
<Input {...updateBookMetadata.fields.subtitle.as('text')} /> <Input {...updateBookMetadata.fields.subtitle.as('text')} />
{#each updateBookMetadata.fields.subtitle.issues() ?? [] as issue} {#each updateBookMetadata.fields.subtitle.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -144,7 +144,7 @@
<Field.Field> <Field.Field>
<Field.Label for="edition">Edition</Field.Label> <Field.Label for="edition">Edition</Field.Label>
<Input {...updateBookMetadata.fields.edition.as('number')} /> <Input {...updateBookMetadata.fields.edition.as('number')} />
{#each updateBookMetadata.fields.edition.issues() ?? [] as issue} {#each updateBookMetadata.fields.edition.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -152,7 +152,7 @@
<Field.Field> <Field.Field>
<Field.Label for="series">Series</Field.Label> <Field.Label for="series">Series</Field.Label>
<Input {...updateBookMetadata.fields.series.as('text')} /> <Input {...updateBookMetadata.fields.series.as('text')} />
{#each updateBookMetadata.fields.series.issues() ?? [] as issue} {#each updateBookMetadata.fields.series.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -161,7 +161,7 @@
<Field.Field> <Field.Field>
<Field.Label for="series_position">No.</Field.Label> <Field.Label for="series_position">No.</Field.Label>
<Input {...updateBookMetadata.fields.series_position.as('text')} /> <Input {...updateBookMetadata.fields.series_position.as('text')} />
{#each updateBookMetadata.fields.series_position.issues() ?? [] as issue} {#each updateBookMetadata.fields.series_position.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -169,7 +169,7 @@
<Field.Field> <Field.Field>
<Field.Label for="language">Language</Field.Label> <Field.Label for="language">Language</Field.Label>
<Input {...updateBookMetadata.fields.language.as('text')} /> <Input {...updateBookMetadata.fields.language.as('text')} />
{#each updateBookMetadata.fields.language.issues() ?? [] as issue} {#each updateBookMetadata.fields.language.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -185,10 +185,10 @@
placeholder="Add an author" placeholder="Add an author"
class="min-h-10 p-2 text-sm" class="min-h-10 p-2 text-sm"
/> />
{#each authors as author} {#each authors as author, i (i)}
<input class="hidden" {...updateBookMetadata.fields.authors.as('checkbox', author)} /> <input class="hidden" {...updateBookMetadata.fields.authors.as('checkbox', author)} />
{/each} {/each}
{#each updateBookMetadata.fields.authors.issues() ?? [] as issue} {#each updateBookMetadata.fields.authors.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -201,10 +201,10 @@
placeholder="Add a tag" placeholder="Add a tag"
class="min-h-10 p-2 text-sm" class="min-h-10 p-2 text-sm"
/> />
{#each tags as tag} {#each tags as tag, i (i)}
<input class="hidden" {...updateBookMetadata.fields.tags.as('checkbox', tag)} /> <input class="hidden" {...updateBookMetadata.fields.tags.as('checkbox', tag)} />
{/each} {/each}
{#each updateBookMetadata.fields.tags.issues() ?? [] as issue} {#each updateBookMetadata.fields.tags.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -214,7 +214,7 @@
<Field.Field> <Field.Field>
<Field.Label for="publisher">Publisher</Field.Label> <Field.Label for="publisher">Publisher</Field.Label>
<Input {...updateBookMetadata.fields.publisher.as('text')} /> <Input {...updateBookMetadata.fields.publisher.as('text')} />
{#each updateBookMetadata.fields.publisher.issues() ?? [] as issue} {#each updateBookMetadata.fields.publisher.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -223,7 +223,7 @@
<Field.Field> <Field.Field>
<Field.Label for="published_date">Published</Field.Label> <Field.Label for="published_date">Published</Field.Label>
<Input {...updateBookMetadata.fields.published_date.as('date')} /> <Input {...updateBookMetadata.fields.published_date.as('date')} />
{#each updateBookMetadata.fields.published_date.issues() ?? [] as issue} {#each updateBookMetadata.fields.published_date.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -231,7 +231,7 @@
<Field.Field> <Field.Field>
<Field.Label for="pages">Pages</Field.Label> <Field.Label for="pages">Pages</Field.Label>
<Input {...updateBookMetadata.fields.pages.as('number')} /> <Input {...updateBookMetadata.fields.pages.as('number')} />
{#each updateBookMetadata.fields.pages.issues() ?? [] as issue} {#each updateBookMetadata.fields.pages.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -240,7 +240,7 @@
<Field.Field class="col-span-full"> <Field.Field class="col-span-full">
<Field.Label for="identifiers">Identifiers</Field.Label> <Field.Label for="identifiers">Identifiers</Field.Label>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#each identifierKeys as _, idx} {#each identifierKeys as _, idx (idx)}
<div class="grid grid-cols-[1fr_1.6fr_auto] gap-2"> <div class="grid grid-cols-[1fr_1.6fr_auto] gap-2">
<Input bind:value={identifierKeys[idx]} placeholder="ISBN, DOI…" /> <Input bind:value={identifierKeys[idx]} placeholder="ISBN, DOI…" />
<Input bind:value={identifierValues[idx]} placeholder="Value" /> <Input bind:value={identifierValues[idx]} placeholder="Value" />
@@ -271,7 +271,7 @@
<Field.Field class="col-span-full"> <Field.Field class="col-span-full">
<Field.Label for="description" class="sr-only">Description</Field.Label> <Field.Label for="description" class="sr-only">Description</Field.Label>
<Textarea {...updateBookMetadata.fields.description.as('text')} rows={6} /> <Textarea {...updateBookMetadata.fields.description.as('text')} rows={6} />
{#each updateBookMetadata.fields.description.issues() ?? [] as issue} {#each updateBookMetadata.fields.description.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -23,7 +23,7 @@
</Dialog.Header> </Dialog.Header>
<form <form
{...createLibrary.enhance(async ({ form, submit }) => { {...createLibrary.enhance(async ({ element, submit }) => {
try { try {
await submit(); await submit();
@@ -35,7 +35,7 @@
const libraryName = createLibrary.fields.name.value(); const libraryName = createLibrary.fields.name.value();
form.reset(); element.reset();
selectedIcon = 'library'; selectedIcon = 'library';
open = false; open = false;
toast.success(`Library '${libraryName}' created.`); toast.success(`Library '${libraryName}' created.`);
@@ -56,7 +56,7 @@
<Field.Field> <Field.Field>
<Field.Label for="name">Library name</Field.Label> <Field.Label for="name">Library name</Field.Label>
<Input {...createLibrary.fields.name.as('text')} /> <Input {...createLibrary.fields.name.as('text')} />
{#each createLibrary.fields.name.issues() ?? [] as issue} {#each createLibrary.fields.name.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -73,7 +73,7 @@
<Field.Field> <Field.Field>
<Field.Label for="description">Description</Field.Label> <Field.Label for="description">Description</Field.Label>
<Textarea {...createLibrary.fields.description.as('text')} /> <Textarea {...createLibrary.fields.description.as('text')} />
{#each createLibrary.fields.description.issues() ?? [] as issue} {#each createLibrary.fields.description.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -82,7 +82,7 @@
<Field.Field> <Field.Field>
<Field.Label for="root_path">Root Path</Field.Label> <Field.Label for="root_path">Root Path</Field.Label>
<Input {...createLibrary.fields.root_path.as('text')} /> <Input {...createLibrary.fields.root_path.as('text')} />
{#each createLibrary.fields.root_path.issues() ?? [] as issue} {#each createLibrary.fields.root_path.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
<Field.Description class="text-xs"> <Field.Description class="text-xs">
@@ -94,7 +94,7 @@
<Field.Field> <Field.Field>
<Field.Label for="path_template">Path Template</Field.Label> <Field.Label for="path_template">Path Template</Field.Label>
<Input {...createLibrary.fields.path_template.as('text')} /> <Input {...createLibrary.fields.path_template.as('text')} />
{#each createLibrary.fields.path_template.issues() ?? [] as issue} {#each createLibrary.fields.path_template.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
<Field.Description class="text-xs"> <Field.Description class="text-xs">
@@ -21,7 +21,7 @@
<Field.Field> <Field.Field>
<Field.Label for="email">Email</Field.Label> <Field.Label for="email">Email</Field.Label>
<Input {...login.fields.email.as('email')} /> <Input {...login.fields.email.as('email')} />
{#each login.fields.email.issues() ?? [] as issue} {#each login.fields.email.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -30,7 +30,7 @@
<Field.Field> <Field.Field>
<Field.Label for="password">Password</Field.Label> <Field.Label for="password">Password</Field.Label>
<Input {...login.fields.password.as('password')} /> <Input {...login.fields.password.as('password')} />
{#each login.fields.password.issues() ?? [] as issue} {#each login.fields.password.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -1,15 +1,16 @@
<script lang="ts"> <script lang="ts">
import * as Dialog from '$lib/components/ui/dialog/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Button } from "$lib/components/ui/button/index.js"; import { Button } from '$lib/components/ui/button/index.js';
import { Input } from "$lib/components/ui/input/index.js"; import { Input } from '$lib/components/ui/input/index.js';
import { Label } from "$lib/components/ui/label/index.js"; import { Label } from '$lib/components/ui/label/index.js';
let { open = $bindable(), onSubmit }: { open?: boolean, onSubmit: (name: string) => Promise<undefined> } = $props()
let shelfName = $state('')
let {
open = $bindable(),
onSubmit
}: { open?: boolean; onSubmit: (name: string) => Promise<undefined> } = $props();
let shelfName = $state('');
</script> </script>
<Dialog.Root bind:open> <Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[425px]"> <Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header> <Dialog.Header>
@@ -18,7 +19,7 @@
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<Label>Name</Label> <Label>Name</Label>
<Input bind:value={shelfName}/> <Input bind:value={shelfName} />
</div> </div>
<!-- Buttons --> <!-- Buttons -->
@@ -17,7 +17,7 @@
</Card.Header> </Card.Header>
<form <form
{...signup.enhance(async ({ submit, form }) => { {...signup.enhance(async ({ submit, element }) => {
try { try {
await submit(); await submit();
@@ -29,7 +29,7 @@
// Move to login tab on success // Move to login tab on success
// TODO: Fix previous errors showing on login form // TODO: Fix previous errors showing on login form
form.reset(); element.reset();
toast.success('Successfully registered!'); toast.success('Successfully registered!');
login.fields.set({ email: '', password: '' }); login.fields.set({ email: '', password: '' });
login.validate(); login.validate();
@@ -47,7 +47,7 @@
<Field.Field> <Field.Field>
<Field.Label for="email">Email</Field.Label> <Field.Label for="email">Email</Field.Label>
<Input {...signup.fields.email.as('email')} /> <Input {...signup.fields.email.as('email')} />
{#each signup.fields.email.issues() ?? [] as issue} {#each signup.fields.email.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -56,7 +56,7 @@
<Field.Field> <Field.Field>
<Field.Label for="password">Password</Field.Label> <Field.Label for="password">Password</Field.Label>
<Input {...signup.fields.password.as('password')} /> <Input {...signup.fields.password.as('password')} />
{#each signup.fields.password.issues() ?? [] as issue} {#each signup.fields.password.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -65,7 +65,7 @@
<Field.Field> <Field.Field>
<Field.Label for="confirmPassword">Confirm Password</Field.Label> <Field.Label for="confirmPassword">Confirm Password</Field.Label>
<Input {...signup.fields.confirmPassword.as('password')} /> <Input {...signup.fields.confirmPassword.as('password')} />
{#each signup.fields.confirmPassword.issues() ?? [] as issue} {#each signup.fields.confirmPassword.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
@@ -28,7 +28,9 @@
// directly in the markup keeps it static. // directly in the markup keeps it static.
const header = $derived({ const header = $derived({
title: 'chitai', title: 'chitai',
url: resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(libraryState.activeLibrary!.id) }) url: resolve('/(root)/(library)/library/[libraryId]', {
libraryId: String(libraryState.activeLibrary!.id)
})
}); });
</script> </script>
@@ -2,8 +2,8 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/index.js'; import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import { Badge } from "$lib/components/ui/badge/index.js"; import { Badge } from '$lib/components/ui/badge/index.js';
import { getLibraryState, LibraryState } from '$lib/state/library.svelte'; import { getLibraryState } from '$lib/state/library.svelte';
import { LIBRARY_ICONS } from '$lib/components/ui/icon-picker/index.js'; import { LIBRARY_ICONS } from '$lib/components/ui/icon-picker/index.js';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down'; import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import PlusIcon from '@lucide/svelte/icons/plus'; import PlusIcon from '@lucide/svelte/icons/plus';
@@ -12,7 +12,9 @@
const libraryState = getLibraryState(); const libraryState = getLibraryState();
const sidebar = useSidebar(); const sidebar = useSidebar();
const activeIcon = $derived(LIBRARY_ICONS[libraryState.activeLibrary?.icon ?? 'library'] ?? LIBRARY_ICONS['library']); const activeIcon = $derived(
LIBRARY_ICONS[libraryState.activeLibrary?.icon ?? 'library'] ?? LIBRARY_ICONS['library']
);
</script> </script>
<Sidebar.Menu> <Sidebar.Menu>
@@ -35,7 +37,7 @@
<span class="ml-1 truncate font-semibold"> <span class="ml-1 truncate font-semibold">
{libraryState.activeLibrary!.name} {libraryState.activeLibrary!.name}
</span> </span>
<span class="ml-1 truncate font-mono text-xs tabular-nums text-muted-foreground"> <span class="ml-1 truncate font-mono text-xs text-muted-foreground tabular-nums">
{libraryState.activeLibrary!.total ?? 0} books {libraryState.activeLibrary!.total ?? 0} books
</span> </span>
</div> </div>
@@ -51,15 +53,15 @@
> >
<DropdownMenu.Label class="text-xs text-muted-foreground">Libraries</DropdownMenu.Label> <DropdownMenu.Label class="text-xs text-muted-foreground">Libraries</DropdownMenu.Label>
{#each libraryState.libraries as library (library.name)} {#each libraryState.libraries as library (library.name)}
{@const LibraryIcon = (LIBRARY_ICONS[library.icon ?? 'library'] ?? LIBRARY_ICONS['library']).component} {@const LibraryIcon = (
LIBRARY_ICONS[library.icon ?? 'library'] ?? LIBRARY_ICONS['library']
).component}
<DropdownMenu.Item onSelect={() => libraryState.setActive(library.id)} class="gap-2 p-2"> <DropdownMenu.Item onSelect={() => libraryState.setActive(library.id)} class="gap-2 p-2">
<div class="flex size-6 items-center justify-center rounded-md border"> <div class="flex size-6 items-center justify-center rounded-md border">
<LibraryIcon class="size-3.5 shrink-0" /> <LibraryIcon class="size-3.5 shrink-0" />
</div> </div>
{library.name} {library.name}
<Badge <Badge variant="outline" class="ml-auto font-semibold">
variant="outline"
class="font-semibold ml-auto">
{library.total ?? 0} {library.total ?? 0}
</Badge> </Badge>
</DropdownMenu.Item> </DropdownMenu.Item>
@@ -53,9 +53,9 @@
} }
} }
function handleClick(e: any) { function handleClick(e: Event & { currentTarget: HTMLElement }) {
open = true; open = true;
e.target.blur(); e.currentTarget.blur();
} }
</script> </script>
@@ -93,7 +93,7 @@
</div> </div>
{:else} {:else}
<Command.List class="max-h-[600px]"> <Command.List class="max-h-[600px]">
{#if searchResult?.items.length > 0} {#if (searchResult?.items?.length ?? 0) > 0}
<Command.Group heading="Books"> <Command.Group heading="Books">
{#each searchResult?.items as book (book.id)} {#each searchResult?.items as book (book.id)}
<Command.Item <Command.Item
@@ -121,7 +121,7 @@
{#if book.authors.length > 0} {#if book.authors.length > 0}
<span class="line-clamp-1 w-full text-sm text-muted-foreground"> <span class="line-clamp-1 w-full text-sm text-muted-foreground">
by by
{#each book.authors as author} {#each book.authors as author (author.id)}
<a <a
href="{resolve('/(root)/(library)/library/[libraryId]/view', { href="{resolve('/(root)/(library)/library/[libraryId]/view', {
libraryId: String(libraryState.activeLibrary!.id) libraryId: String(libraryState.activeLibrary!.id)
@@ -132,7 +132,7 @@
</span> </span>
{/if} {/if}
<div class="mt-1 ml-[-1.5] flex"> <div class="mt-1 ml-[-1.5] flex">
{#each book.tags as tag} {#each book.tags as tag (tag.id)}
<Badge class="scale-75">{tag.name}</Badge> <Badge class="scale-75">{tag.name}</Badge>
{/each} {/each}
</div> </div>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -14,10 +14,10 @@
bind:this={ref} bind:this={ref}
data-slot="avatar-badge" data-slot="avatar-badge"
class={cn( class={cn(
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none", 'absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none',
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden", 'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", 'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", 'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
className className
)} )}
{...restProps} {...restProps}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui"; import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +13,7 @@
bind:ref bind:ref
data-slot="avatar-fallback" data-slot="avatar-fallback"
class={cn( class={cn(
"rounded-full bg-muted text-muted-foreground flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs", 'flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs',
className className
)} )}
{...restProps} {...restProps}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -14,7 +14,7 @@
bind:this={ref} bind:this={ref}
data-slot="avatar-group-count" data-slot="avatar-group-count"
class={cn( class={cn(
"size-8 rounded-full bg-muted text-sm text-muted-foreground group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 relative flex shrink-0 items-center justify-center ring-2 ring-background", 'relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3',
className className
)} )}
{...restProps} {...restProps}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -14,7 +14,7 @@
bind:this={ref} bind:this={ref}
data-slot="avatar-group" data-slot="avatar-group"
class={cn( class={cn(
"cn-avatar-group group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background", 'cn-avatar-group group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background',
className className
)} )}
{...restProps} {...restProps}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui"; import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -12,6 +12,6 @@
<AvatarPrimitive.Image <AvatarPrimitive.Image
bind:ref bind:ref
data-slot="avatar-image" data-slot="avatar-image"
class={cn("rounded-full aspect-square size-full object-cover", className)} class={cn('aspect-square size-full rounded-full object-cover', className)}
{...restProps} {...restProps}
/> />
@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui"; import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
loadingStatus = $bindable("loading"), loadingStatus = $bindable('loading'),
size = "default", size = 'default',
class: className, class: className,
...restProps ...restProps
}: AvatarPrimitive.RootProps & { }: AvatarPrimitive.RootProps & {
size?: "default" | "sm" | "lg"; size?: 'default' | 'sm' | 'lg';
} = $props(); } = $props();
</script> </script>
@@ -19,7 +19,7 @@
data-slot="avatar" data-slot="avatar"
data-size={size} data-size={size}
class={cn( class={cn(
"size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:border-border after:mix-blend-darken dark:after:mix-blend-lighten", 'group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten',
className className
)} )}
{...restProps} {...restProps}
@@ -1,9 +1,9 @@
import Badge from "./avatar-badge.svelte"; import Badge from './avatar-badge.svelte';
import Fallback from "./avatar-fallback.svelte"; import Fallback from './avatar-fallback.svelte';
import GroupCount from "./avatar-group-count.svelte"; import GroupCount from './avatar-group-count.svelte';
import Group from "./avatar-group.svelte"; import Group from './avatar-group.svelte';
import Image from "./avatar-image.svelte"; import Image from './avatar-image.svelte';
import Root from "./avatar.svelte"; import Root from './avatar.svelte';
export { export {
Root, Root,
@@ -18,5 +18,5 @@ export {
Fallback as AvatarFallback, Fallback as AvatarFallback,
Badge as AvatarBadge, Badge as AvatarBadge,
Group as AvatarGroup, Group as AvatarGroup,
GroupCount as AvatarGroupCount, GroupCount as AvatarGroupCount
}; };
@@ -15,8 +15,7 @@
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline', link: 'text-primary underline-offset-4 hover:underline',
accent: accent: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90'
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90'
}, },
size: { size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3', default: 'h-9 px-4 py-2 has-[>svg]:px-3',
@@ -185,7 +185,6 @@
type="file" type="file"
onchange={change} onchange={change}
webkitdirectory={directory} webkitdirectory={directory}
{directory}
class="hidden" class="hidden"
/> />
</label> </label>
@@ -6,9 +6,7 @@ import type { WithChildren } from 'bits-ui';
import type { HTMLInputAttributes } from 'svelte/elements'; import type { HTMLInputAttributes } from 'svelte/elements';
export type FileRejectedReason = export type FileRejectedReason =
| 'Maximum file size exceeded' 'Maximum file size exceeded' | 'File type not allowed' | 'Maximum files uploaded';
| 'File type not allowed'
| 'Maximum files uploaded';
export type FileDropZonePropsWithoutHTML = WithChildren<{ export type FileDropZonePropsWithoutHTML = WithChildren<{
ref?: HTMLInputElement | null; ref?: HTMLInputElement | null;
@@ -37,7 +37,7 @@
</script> </script>
<Popover.Root bind:open> <Popover.Root bind:open>
<Popover.Trigger asChild> <Popover.Trigger>
{#snippet child({ props })} {#snippet child({ props })}
<Button variant="outline" size="icon" class="h-9 w-9" {...props}> <Button variant="outline" size="icon" class="h-9 w-9" {...props}>
<SelectedIconComponent class="size-4" /> <SelectedIconComponent class="size-4" />
@@ -45,11 +45,7 @@
{/snippet} {/snippet}
</Popover.Trigger> </Popover.Trigger>
<Popover.Content class="w-64 p-3" align="start"> <Popover.Content class="w-64 p-3" align="start">
<Input <Input bind:value={search} placeholder="Search icons..." class="mb-3 h-8" />
bind:value={search}
placeholder="Search icons..."
class="mb-3 h-8"
/>
<ScrollArea class="h-48"> <ScrollArea class="h-48">
<div class="grid grid-cols-6 gap-1"> <div class="grid grid-cols-6 gap-1">
{#each filteredIcons as [key, icon] (key)} {#each filteredIcons as [key, icon] (key)}
@@ -58,7 +54,10 @@
<Tooltip.Trigger> <Tooltip.Trigger>
<button <button
type="button" type="button"
class="flex size-8 items-center justify-center rounded-md hover:bg-accent {value === key ? 'bg-accent' : ''}" class="flex size-8 items-center justify-center rounded-md hover:bg-accent {value ===
key
? 'bg-accent'
: ''}"
onclick={() => selectIcon(key)} onclick={() => selectIcon(key)}
> >
<IconComponent class="size-4" /> <IconComponent class="size-4" />
@@ -59,7 +59,10 @@ import type { Component } from 'svelte';
export type IconName = keyof typeof LIBRARY_ICONS; export type IconName = keyof typeof LIBRARY_ICONS;
export const LIBRARY_ICONS: Record<string, { component: Component; label: string; category: string }> = { export const LIBRARY_ICONS: Record<
string,
{ component: Component; label: string; category: string }
> = {
// Generic // Generic
library: { component: Library, label: 'Library', category: 'Generic' }, library: { component: Library, label: 'Library', category: 'Generic' },
'book-open': { component: BookOpen, label: 'Book Open', category: 'Generic' }, 'book-open': { component: BookOpen, label: 'Book Open', category: 'Generic' },
@@ -1,8 +1,8 @@
import Root from "./popover.svelte"; import Root from './popover.svelte';
import Close from "./popover-close.svelte"; import Close from './popover-close.svelte';
import Content from "./popover-content.svelte"; import Content from './popover-content.svelte';
import Trigger from "./popover-trigger.svelte"; import Trigger from './popover-trigger.svelte';
import Portal from "./popover-portal.svelte"; import Portal from './popover-portal.svelte';
export { export {
Root, Root,
@@ -15,5 +15,5 @@ export {
Content as PopoverContent, Content as PopoverContent,
Trigger as PopoverTrigger, Trigger as PopoverTrigger,
Close as PopoverClose, Close as PopoverClose,
Portal as PopoverPortal, Portal as PopoverPortal
}; };
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui"; import { Popover as PopoverPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props(); let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
</script> </script>
@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui"; import { Popover as PopoverPrimitive } from 'bits-ui';
import PopoverPortal from "./popover-portal.svelte"; import PopoverPortal from './popover-portal.svelte';
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js"; import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import type { ComponentProps } from "svelte"; import type { ComponentProps } from 'svelte';
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
sideOffset = 4, sideOffset = 4,
align = "center", align = 'center',
portalProps, portalProps,
...restProps ...restProps
}: PopoverPrimitive.ContentProps & { }: PopoverPrimitive.ContentProps & {
@@ -23,7 +23,7 @@
{sideOffset} {sideOffset}
{align} {align}
class={cn( class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden", 'z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className className
)} )}
{...restProps} {...restProps}
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui"; import { Popover as PopoverPrimitive } from 'bits-ui';
let { ...restProps }: PopoverPrimitive.PortalProps = $props(); let { ...restProps }: PopoverPrimitive.PortalProps = $props();
</script> </script>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
import { Popover as PopoverPrimitive } from "bits-ui"; import { Popover as PopoverPrimitive } from 'bits-ui';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -12,6 +12,6 @@
<PopoverPrimitive.Trigger <PopoverPrimitive.Trigger
bind:ref bind:ref
data-slot="popover-trigger" data-slot="popover-trigger"
class={cn("", className)} class={cn('', className)}
{...restProps} {...restProps}
/> />
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui"; import { Popover as PopoverPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props(); let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
</script> </script>
@@ -36,7 +36,6 @@
$effect(() => { $effect(() => {
// whenever input value changes reset invalid // whenever input value changes reset invalid
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
inputValue; inputValue;
untrack(() => { untrack(() => {

Some files were not shown because too many files have changed in this diff Show More