18 Commits
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
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)
CHITAI_DEFAULT_LIBRARY_NAME=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
uv run litestar --app-dir src/chitai/ run --reload # dev server on :8000
pytest tests/ # needs Docker (pytest-databases)
ruff format src/
ruff format src/ tests/
alchemy --config chitai.database.config.config make-migrations
alchemy --config chitai.database.config.config upgrade
@@ -103,6 +103,13 @@ API docs are served by the running backend at `http://localhost:8000/schema/` (S
manually against a live backend).
- **Migrations are mandatory.** The app runs with `create_all=False`, so a model change without a
matching Alembic revision will not reach the database.
- **CI gates formatting, linting, 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:`.
- The three `README.md` files are user-facing. Agent-facing knowledge belongs in the `AGENTS.md`
files.
+11 -5
View File
@@ -26,12 +26,18 @@ eBook library management application.
### 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`
3. Build the Docker images: `docker compose build`
4. Copy the example environment file and configure: `cp .env.prod-example .env`
5. Run the Docker containers in detached mode: `docker compose up -d`
6. Access the frontend at: `http://localhost:3000/`
3. Copy the example environment file: `cp .env.prod-example .env`
4. Edit `.env`. At minimum set `CHITAI_TOKEN_SECRET` to something random, and set
`CHITAI_ORIGIN` to the URL you will reach the app on — logging in fails with a 403 if it
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
+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
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
114 errors — 100 of them unused imports, the rest bare `except`, unused variables and
`== True` comparisons. `ruff check --fix` clears 51 automatically.
`backend/tests/integration/test_book.py``test_remove_file_with_delete_files_false_keeps_filesystem_file`
and `test_remove_file_with_delete_files_true_removes_filesystem_file`
There is no `[tool.ruff]` section in `pyproject.toml`, so only ruff's default `E4/E7/E9/F`
rules run, and no type checker is configured at all despite `# type: ignore` comments in
the tree. Individually these are trivial; collectively they say nothing runs on commit.
Both tests capture the file's path and then assert only `response.status_code == 204`.
Neither looks at the disk. So the flag that decides whether removing a file from a book
also **erases it from the filesystem** is covered in name only, in both directions.
Ruff surfaced this as two `F841` unused variables; the variables carry a `# noqa: F841`
and a comment rather than being deleted, so the gap stays visible. Remove the noqa when
the assertions land.
The reason it is not a two-line fix: `FileMetadata.path` is stored relative to `book.path`,
so the test has to resolve it against the library root to know what to stat. That
resolution is the same thing `BookService.get_files` is recorded as getting wrong (see
`backend/AGENTS.md`), so it is worth settling once and using in both places.
### No type checker on the backend
Formatting, linting and tests now run in CI (`.gitea/workflows/ci.yml`) and block, and the
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
### Book descriptions are rendered as unsanitized HTML
`frontend/src/lib/components/ui/collapsible-text/collapsible-text.svelte` — lines 42 and 45
The component renders `{@html text}`, and its only caller is the book detail page:
`<CollapsibleText text={book.description} maxLength={500} />`. So whatever is in
`Book.description` reaches the DOM as markup.
The Calibre importer is fine — `services/calibre.py:401` passes comments through
`strip_html`, because Calibre stores HTML there. But `strip_html` is used **nowhere else in
the backend**, and `EpubExtractor._extract_description` returns
`epub.get_metadata("DC", "description")[0][0]` verbatim. EPUB `dc:description` routinely
carries markup, so an uploaded book with `<img src=x onerror=…>` in that field executes
script on the book page, with the session cookie in scope. Metadata edited through the UI
is stored unfiltered too.
This is the same class as the scripted-EPUB item below — untrusted file content reaching an
origin that holds a session — by a different route, and it does not need `allow-scripts` to
work.
Fix: sanitize at ingest, next to where Calibre already does. Reuse `strip_html` in
`_extract_description` if descriptions should be plain text, or run an allowlist sanitizer if
the formatting is worth keeping. Either way the stored rows need backfilling through the same
helper, since the validators only fire on write. Dropping `{@html}` to `{text}` in the
component fixes the display side but leaves the payload in the database.
These are the only two findings `pnpm exec eslint .` still reports; CI's eslint step stops
being `continue-on-error` once they are gone.
### Scripted EPUBs run against the app origin
**This is a regression from the foliate-js migration, not a pre-existing gap.**
+6
View File
@@ -36,8 +36,14 @@ dev = [
"pytest>=8.4.2",
"pytest-asyncio>=1.2.0",
"pytest-databases[postgres]>=0.15.0",
"ruff==0.15.14",
]
[tool.ruff.lint.per-file-ignores]
# 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]
asyncio_mode = "auto"
testpaths = ["tests"]
+2 -1
View File
@@ -4,7 +4,8 @@ pkgs.mkShell {
buildInputs = with pkgs; [
# Python development environment for Chitai
python313Packages.greenlet
python313Packages.ruff
# ruff is a dev dependency in pyproject.toml, not a shell package: CI has no nix, so a
# nix-only formatter is one CI cannot run, and two copies could disagree on formatting.
uv
# postgres database
+5 -1
View File
@@ -72,6 +72,7 @@ oauth2_auth = OAuth2PasswordBearerAuth[User](
watcher_task: asyncio.Task
@asynccontextmanager
async def setup_db_connection(app: Litestar) -> AsyncGenerator[None, None]:
# Setup databse
@@ -107,7 +108,9 @@ async def setup_directory_watcher(app: Litestar) -> AsyncGenerator[None, None]:
book_service = BookService(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())
try:
@@ -115,6 +118,7 @@ async def setup_directory_watcher(app: Litestar) -> AsyncGenerator[None, None]:
finally:
watcher_task.cancel()
def create_app() -> Litestar:
return Litestar(
route_handlers=[
+1 -1
View File
@@ -1,7 +1,7 @@
# src/chitai/controllers/access.py
# Standard library
from typing import Annotated, Any
from typing import Annotated
import logging
# Third-party libraries
+1 -2
View File
@@ -4,9 +4,8 @@
from typing import Annotated
# Third-party libraries
from litestar import Controller, post, get, patch, delete
from litestar import Controller, get
from litestar.params import Dependency
from litestar.exceptions import HTTPException
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
from advanced_alchemy.service.pagination import OffsetPagination
from advanced_alchemy.service import FilterTypeT
+23 -19
View File
@@ -8,47 +8,51 @@ from litestar import Controller, post, get, delete
from litestar.di import Provide
from chitai.services import dependencies as deps
class DeviceController(Controller):
"""Controller for managing KOReader devices."""
dependencies = {
"device_service": Provide(deps.provide_kosync_device_service)
}
dependencies = {"device_service": Provide(deps.provide_kosync_device_service)}
path = "/devices"
@get()
async def get_devices(self, device_service: KosyncDeviceService, current_user: User) -> OffsetPagination[KosyncDeviceRead]:
async def get_devices(
self, device_service: KosyncDeviceService, current_user: User
) -> OffsetPagination[KosyncDeviceRead]:
"""Return a list of all the user's devices."""
devices = await device_service.list(
KosyncDevice.user_id == current_user.id
)
devices = await device_service.list(KosyncDevice.user_id == current_user.id)
return device_service.to_schema(devices, schema_type=KosyncDeviceRead)
@post()
async def create_device(self, data: KosyncDeviceCreate, device_service: KosyncDeviceService, current_user: User) -> KosyncDeviceRead:
device = await device_service.create({
'name': data.name,
'user_id': current_user.id
})
async def create_device(
self,
data: KosyncDeviceCreate,
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)
@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
device = await device_service.get_one(
KosyncDevice.id == device_id,
KosyncDevice.user_id == current_user.id
KosyncDevice.id == device_id, KosyncDevice.user_id == current_user.id
)
await device_service.delete(device.id)
@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
device = await device_service.get_one(
KosyncDevice.id == device_id,
KosyncDevice.user_id == current_user.id
KosyncDevice.id == device_id, KosyncDevice.user_id == current_user.id
)
updated_device = await device_service.regenerate_api_key(device.id)
return device_service.to_schema(updated_device, schema_type=KosyncDeviceRead)
@@ -58,10 +58,14 @@ class KosyncController(Controller):
user: m.User,
) -> KosyncProgressRead:
"""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:
raise HTTPException(status_code=404, detail="No progress found for document")
raise HTTPException(
status_code=404, detail="No progress found for document"
)
return KosyncProgressRead(
document=progress.document,
@@ -85,6 +89,3 @@ class KosyncController(Controller):
detail="User accounts must be created via the main application",
status_code=HTTP_403_FORBIDDEN,
)
+4 -2
View File
@@ -10,7 +10,7 @@ from typing import Annotated
# Third-party libraries
import aiofiles
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.params import Body, Dependency
from litestar.exceptions import HTTPException
@@ -89,7 +89,9 @@ class LibraryController(Controller):
Injected Dependencies:
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(
results, total, filters, schema_type=LibraryRead
)
+65 -51
View File
@@ -1,4 +1,3 @@
from chitai.services import dependencies as deps
from chitai.database import models as m
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.tags import TagLibraryFilter
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.publisher import PublisherService
from chitai.services.tag import TagService
@@ -22,7 +29,6 @@ from litestar.params import Dependency
from advanced_alchemy.service import FilterTypeT
class OpdsController(Controller):
"""Controller for managing OPDS endpoints"""
@@ -76,10 +82,11 @@ class OpdsController(Controller):
title=lib.name,
href=f"/opds/library/{lib.id}",
rel=LinkRelations.SUBSECTION,
type=LinkTypes.NAVIGATION
type=LinkTypes.NAVIGATION,
)
]
) for lib in libraries
],
)
for lib in libraries
]
feed = create_navigation_feed(
@@ -94,13 +101,10 @@ class OpdsController(Controller):
title="Search books",
)
],
entries=entries
entries=entries,
)
return Response(
feed,
media_type="application/xml"
)
return Response(feed, media_type="application/xml")
@get("/acquisition")
async def get_acquisition_feed(
@@ -109,8 +113,10 @@ class OpdsController(Controller):
feed_id: str,
feed_title: str,
books_service: BookService,
book_filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [],
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = []
book_filters: Annotated[
list[FilterTypeT], Dependency(skip_validation=True)
] = [],
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [],
) -> Response:
all_filters = [*filters, *book_filters]
@@ -121,20 +127,22 @@ class OpdsController(Controller):
links = []
# 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(
request=request,
total=total,
limit=limit,
offset=offset,
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
if request.query_params.get('search'):
if request.query_params.get("search"):
links.append(create_search_link(request))
# Create self URL
@@ -150,15 +158,14 @@ class OpdsController(Controller):
return Response(feed, media_type="application/xml")
@get("/opensearch")
async def opensearch(self, user: m.User, request: Request) -> Response:
return Response(
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}")
@@ -168,7 +175,6 @@ class OpdsController(Controller):
return Response(feed, media_type="application/xml")
@get("/library/{library_id:int}/{collection_type:str}")
async def get_library_collection_feed(
self,
@@ -180,34 +186,46 @@ class OpdsController(Controller):
tag_service: TagService,
publisher_service: PublisherService,
request: Request,
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = []
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [],
) -> Response:
service_map = {
'shelves': (shelf_service, lambda: shelf_service.list_and_count(
"shelves": (
shelf_service,
lambda: shelf_service.list_and_count(
*filters,
CollectionFilter("library_id", values=[library.id]),
OrderBy("name", "asc"),
m.BookList.user_id == user.id
)),
'tags': (tag_service, lambda: tag_service.list_and_count(
m.BookList.user_id == user.id,
),
),
"tags": (
tag_service,
lambda: tag_service.list_and_count(
*filters,
TagLibraryFilter(libraries=[library.id]),
OrderBy("name", "asc"),
uniquify=True,
)),
'authors': (author_service, lambda: author_service.list_and_count(
),
),
"authors": (
author_service,
lambda: author_service.list_and_count(
*filters,
AuthorLibraryFilter(libraries=[library.id]),
OrderBy("name", "asc"),
uniquify=True
)),
'publishers': (publisher_service, lambda: publisher_service.list_and_count(
uniquify=True,
),
),
"publishers": (
publisher_service,
lambda: publisher_service.list_and_count(
*filters,
PublisherLibraryFilter(libraries=[library.id]),
OrderBy("name", "asc"),
uniquify=True
))
uniquify=True,
),
),
}
if collection_type not in service_map:
@@ -220,38 +238,37 @@ class OpdsController(Controller):
# Create pagination links if it is a paginated feed
limit, offset = extract_limit_offset(filters)
if request.query_params.get('paginated'):
if request.query_params.get("paginated"):
pagination = create_pagination_links(
request=request,
total=total,
limit=limit,
offset=offset,
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)
return Response(feed, media_type="application/xml")
@get("/search")
async def search_books(
self, books_service: BookService,
self,
books_service: BookService,
request: Request,
book_filters: Annotated[
list[FilterTypeT], Dependency(skip_validation=True)
] = [],
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = []
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [],
) -> Response:
filters = [*filters, *book_filters]
books, total = await books_service.list_and_count(
*filters
)
books, total = await books_service.list_and_count(*filters)
limit, offset = extract_limit_offset(filters)
@@ -262,17 +279,17 @@ class OpdsController(Controller):
limit=limit,
offset=offset,
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]
catalog_xml = create_acquisition_feed(
id=f"/opds/search?q=q",
id="/opds/search?q=q",
title="Search results",
url=f"/opds/search?q=q",
url="/opds/search?q=q",
books=books,
links=links
links=links,
)
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]:
"""Extract page size and offset from filters"""
limit_offset_filter = next(
(f for f in filters if isinstance(f, LimitOffset)),
None
)
limit_offset_filter = next((f for f in filters if isinstance(f, LimitOffset)), None)
if limit_offset_filter:
return limit_offset_filter.limit, limit_offset_filter.offset
+1 -1
View File
@@ -4,7 +4,7 @@
from typing import Annotated
# Third-party libraries
from litestar import Controller, post, get, patch, delete
from litestar import Controller, get
from litestar.params import Dependency
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
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(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)
@post()
+1 -1
View File
@@ -4,7 +4,7 @@
from typing import Annotated
# Third-party libraries
from litestar import Controller, post, get, patch, delete
from litestar import Controller, get
from litestar.params import Dependency
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
from advanced_alchemy.service.pagination import OffsetPagination
@@ -1,7 +1,6 @@
from typing import Optional
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import relationship
from advanced_alchemy.base import BigIntAuditBase
@@ -1,12 +1,10 @@
from collections.abc import Hashable
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.mixins import UniqueMixin
from .book import Book
class BookSeries(BigIntAuditBase, UniqueMixin):
__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_column
from advanced_alchemy.base import BigIntAuditBase
class KosyncDevice(BigIntAuditBase):
__tablename__ = "devices"
@@ -1,6 +1,4 @@
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
+10 -5
View File
@@ -3,7 +3,7 @@ from chitai.services.user import UserService
from litestar.middleware import (
AbstractAuthenticationMiddleware,
AuthenticationResult,
DefineMiddleware
DefineMiddleware,
)
from litestar.connection import ASGIConnection
from litestar.exceptions import NotAuthorizedException, PermissionDeniedException
@@ -11,7 +11,9 @@ from chitai.config import settings
class BasicAuthenticationMiddleware(AbstractAuthenticationMiddleware):
async def authenticate_request(self, connection: ASGIConnection) -> AuthenticationResult:
async def authenticate_request(
self, connection: ASGIConnection
) -> AuthenticationResult:
"""Given a request, parse the header for Base64 encoded basic auth credentials."""
# retrieve the auth header
@@ -19,11 +21,14 @@ class BasicAuthenticationMiddleware(AbstractAuthenticationMiddleware):
if not auth_header:
raise NotAuthorizedException()
username, password = b64decode(auth_header.split("Basic ")[1]).decode().split(":")
username, password = (
b64decode(auth_header.split("Basic ")[1]).decode().split(":")
)
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 = await user_service.authenticate(username, password)
return AuthenticationResult(user=user, auth=None)
+7 -3
View File
@@ -3,7 +3,7 @@ from chitai.services.kosync_device import KosyncDeviceService
from litestar.middleware import (
AbstractAuthenticationMiddleware,
AuthenticationResult,
DefineMiddleware
DefineMiddleware,
)
from litestar.connection import ASGIConnection
from litestar.exceptions import NotAuthorizedException, PermissionDeniedException
@@ -11,7 +11,9 @@ from chitai.config import settings
class KosyncAuthenticationMiddleware(AbstractAuthenticationMiddleware):
async def authenticate_request(self, connection: ASGIConnection) -> AuthenticationResult:
async def authenticate_request(
self, connection: ASGIConnection
) -> AuthenticationResult:
"""Given a request, parse the header for Base64 encoded basic auth credentials."""
# retrieve the auth header
@@ -20,7 +22,9 @@ class KosyncAuthenticationMiddleware(AbstractAuthenticationMiddleware):
raise NotAuthorizedException()
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)
device_service = KosyncDeviceService(db_session)
-2
View File
@@ -273,5 +273,3 @@ class BookProgressCreate(BaseModel):
completed: bool | None = None
device_type: 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 pydantic import BaseModel, ConfigDict, Field, SkipValidation, computed_field
from litestar.datastructures import UploadFile
from advanced_alchemy.utils.text import slugify
class LibraryCreate(BaseModel):
name: Annotated[str, Field(min_length=1)]
root_path: str
@@ -17,6 +17,7 @@ class LibraryCreate(BaseModel):
def slug(self) -> str:
return slugify(self.name)
class LibraryRead(BaseModel):
id: int
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):
+67 -33
View File
@@ -19,7 +19,7 @@ from advanced_alchemy.service import (
SQLAlchemyAsyncRepositoryService,
ModelDictT,
is_dict,
schema_dump
schema_dump,
)
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
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
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.
@@ -464,8 +466,6 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
repository_type = Repo
async def create_book(
self,
data: ModelDictT[Book],
@@ -647,7 +647,10 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
return []
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:
@@ -724,7 +727,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
ValueError: If fewer than two distinct books were named, one of them does
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:
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
# reader who has been through the EPUB and not the PDF.
rows = (
(
await session.execute(
select(BookProgress)
.where(BookProgress.book_id.in_([survivor_id, *merged_ids]))
.order_by(BookProgress.percentage.desc())
)
).scalars().all()
)
.scalars()
.all()
)
furthest: dict[int, BookProgress] = {}
for progress in rows:
@@ -902,14 +911,19 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# and only one of them however many books offered it.
held_names = select(Identifier.name).where(Identifier.book_id == survivor_id)
incoming = (
(
await session.execute(
select(Identifier)
.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)
)
).scalars().all()
)
.scalars()
.all()
)
taken: set[str] = set()
for identifier in incoming:
@@ -932,6 +946,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
merged = set(merged_ids)
rows = (
(
await session.execute(
select(DuplicateDismissal).where(
or_(
@@ -940,7 +955,10 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
)
)
)
).scalars().all()
)
.scalars()
.all()
)
existing = await self._dismissed_pairs()
doomed: list[int] = []
@@ -1025,7 +1043,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# copies meet as long as they agree on any one of them.
for title in titles:
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()
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")
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:
raise ValueError("No such book")
@@ -1305,7 +1331,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
**kwargs,
)
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
@@ -1344,7 +1372,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
consume_path: Path,
library: Library,
allow_duplicates: bool = False,
**kwargs
**kwargs,
) -> ImportResult:
"""
Import files that are already on disk, from the consume directory.
@@ -1368,7 +1396,6 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
result = ImportResult()
file_groups: dict[Path, list[Path]] = defaultdict(list)
for file_path in file_paths:
rel_path = file_path.relative_to(consume_path)
parent_rel = rel_path.parent
@@ -1383,7 +1410,6 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# Add to appropriate group
file_groups[group_key].append(file_path)
# For each grouping
for group, files in file_groups.items():
# 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)
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._save_cover_image(data)
@@ -1446,7 +1472,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
book = await super().create(data)
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()
@@ -1554,17 +1582,11 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# What the catalogue claims and what is on disk can disagree: Calibre keeps the
# row when a file is moved away behind its back.
present = [
file.path
for file in entry.files
if await aios.path.isfile(file.path)
file.path for file in entry.files if await aios.path.isfile(file.path)
]
if not present:
reason = (
"no files on disk"
if entry.files
else "no files in the catalogue"
)
reason = "no files on disk" if entry.files else "no files in the catalogue"
result.skipped.append(
UnimportedBook(
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
await move_dir_contents(book.path, 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(
self,
@@ -1979,7 +2005,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
data["files"] = files
new_files = await self._save_book_files(library, data, fingerprints)
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
async def _restore_file(
@@ -2082,7 +2110,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
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.
@@ -2307,7 +2337,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
data["files"] = file_metadata
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.
@@ -2319,7 +2351,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
Returns:
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
for attr in extracted_metadata.keys():
-5
View File
@@ -1,13 +1,8 @@
# src/chitai/services/bookshelf.py
# 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.repository import SQLAlchemyAsyncRepository
from advanced_alchemy.utils.dataclass import Empty, EmptyType
from sqlalchemy import ColumnElement, Select, delete
# Local imports
from chitai.database.models.book_list import BookList, BookListLink
+19 -4
View File
@@ -297,7 +297,9 @@ class CalibreLibrary:
async def count(self) -> int:
"""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])
async def books(self) -> list[CalibreBook]:
@@ -393,7 +395,9 @@ class CalibreLibrary:
calibre_id=book_id,
uuid=str(uuid 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)),
published_date=parse_date(pubdate),
series=in_series,
@@ -510,8 +514,19 @@ class _TextExtractor(HTMLParser):
# a description of three paragraphs comes out as one run-on sentence.
_BREAKS = frozenset(
{
"p", "br", "div", "li", "tr", "blockquote", "hr",
"h1", "h2", "h3", "h4", "h5", "h6",
"p",
"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.database.models.library import Library
from chitai.services import BookService, LibraryService
from chitai.services.metadata_extractor import Extractor
from chitai.services.utils import create_directory
from watchfiles import awatch, Change
class ConsumeDirectoryWatcher:
"""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.
@@ -111,7 +117,6 @@ class ConsumeDirectoryWatcher:
async def _process_batch(self, file_paths: set[Path], library_slug: str):
"""Process a batch of files."""
try:
result = await self.book_service.create_many_from_existing_files(
list(file_paths),
self.watch_path / Path(library_slug),
@@ -138,7 +143,6 @@ class ConsumeDirectoryWatcher:
f"already be in the library as: {names}"
)
except Exception as e:
print(f"Error processing batch: {e}")
raise e
+2 -5
View File
@@ -2,7 +2,7 @@
# Standard library
from __future__ import annotations
from typing import Any, AsyncGenerator, Callable, NotRequired, Optional
from typing import Any, AsyncGenerator, Optional
# Third-party libraries
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.filters import CollectionFilter, StatementFilter
from advanced_alchemy.service import FilterTypeT
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from litestar import Request
@@ -25,7 +24,6 @@ from litestar.di import Provide
from advanced_alchemy.extensions.litestar.providers import create_filter_dependencies
# Local imports
from chitai import schemas as s
from chitai.database import models as m
from chitai.services import (
UserService,
@@ -154,7 +152,6 @@ def create_book_filter_dependencies(
# OVERRIDE: Custom search filter with trigram search
if config.get("search"):
search_fields = config.get("search")
def provide_trigram_search_filter(
search_string: str | None = Parameter(
@@ -370,6 +367,7 @@ def provide_optional_user(request: Request[m.User, Token, Any]) -> m.User | None
return None
async def provide_user_via_basic_auth(request: Request[m.User, None, Any]) -> m.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_progress_service = create_service_provider(KosyncProgressService)
@@ -4,9 +4,7 @@ from pathlib import Path
import re
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.
# 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 advanced_alchemy.filters import (
+9 -5
View File
@@ -3,11 +3,10 @@ from typing import Any, Optional
from dataclasses import dataclass, field
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 (
StatementTypeT,
StatementFilter,
CollectionFilter,
ModelT,
)
@@ -135,7 +134,7 @@ class ProgressFilter(StatementFilter):
status_conditions.append(
and_(
or_(
m.BookProgress.completed == False,
m.BookProgress.completed.is_(False),
m.BookProgress.completed.is_(None),
),
m.BookProgress.percentage > 0,
@@ -143,7 +142,7 @@ class ProgressFilter(StatementFilter):
)
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:
status_conditions.append(m.BookProgress.id.is_(None))
@@ -154,6 +153,7 @@ class ProgressFilter(StatementFilter):
@dataclass
class FileFilter(StatementFilter):
"""Filter books that are related to the given files."""
file_ids: list[int]
def append_to_statement(
@@ -165,17 +165,21 @@ class FileFilter(StatementFilter):
return super().append_to_statement(statement, model, *args, **kwargs)
@dataclass
class FileHashFilter(StatementFilter):
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(
m.Book.files.any(m.FileMetadata.hash.in_(self.file_hashes))
)
return super().append_to_statement(statement, model, *args, **kwargs)
@dataclass
class CustomOrderBy(StatementFilter):
"""Order by filter with support for 'random' and 'last accessed' orderings."""
+7 -3
View File
@@ -1,9 +1,14 @@
from __future__ import annotations
import secrets
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
class KosyncDeviceService(SQLAlchemyAsyncRepositoryService[KosyncDevice]):
"""Service for managing KOReader devices."""
@@ -18,7 +23,7 @@ class KosyncDeviceService(SQLAlchemyAsyncRepositoryService[KosyncDevice]):
async def create(self, data: ModelDictT[KosyncDevice], **kwargs) -> KosyncDevice:
data = schema_dump(data)
data['api_key'] = self._generate_api_key()
data["api_key"] = self._generate_api_key()
return await super().create(data, **kwargs)
async def get_by_api_key(self, api_key: str) -> KosyncDevice:
@@ -30,6 +35,5 @@ class KosyncDeviceService(SQLAlchemyAsyncRepositoryService[KosyncDevice]):
device.api_key = api_key
return await self.update(device)
def _generate_api_key(self) -> str:
return secrets.token_hex(self.API_KEY_LENGTH_IN_BYTES)
@@ -16,7 +16,9 @@ class KosyncProgressService(SQLAlchemyAsyncRepositoryService[KosyncProgress]):
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."""
return await self.get_one_or_none(
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.repository import SQLAlchemyAsyncRepository
from advanced_alchemy import service
from advanced_alchemy.utils.text import slugify
# Local imports
from chitai.database.models.library import Library
@@ -18,6 +17,7 @@ from chitai.services.utils import (
from chitai.config import settings
class LibraryService(SQLAlchemyAsyncRepositoryService[Library]):
"""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?
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}'")
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"
)
# TODO: Verify the read-only library has read permissions
created_library = await super().create(
service.schema_dump(library, exclude_unset=False), **kwargs
@@ -72,9 +71,6 @@ class LibraryService(SQLAlchemyAsyncRepositoryService[Library]):
return created_library
# TODO: Implement library deletion and optional file deletion
async def delete(
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:
# "3rd Edition", "2E", "4e", "8_e", "/6e", "(2nd edition)", "Third International Edition".
_ORDINAL_WORDS = {
"first": 1, "second": 2, "third": 3, "fourth": 4, "fifth": 5, "sixth": 6,
"seventh": 7, "eighth": 8, "ninth": 9, "tenth": 10, "eleventh": 11, "twelfth": 12,
"first": 1,
"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.
@@ -161,7 +171,9 @@ def split_edition(title: str | None) -> tuple[str | None, int | None]:
stripped = _EDITION.sub(" ", title)
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(" ,;:-–—/")
# A title that is only an edition statement is not improved by having none.
@@ -178,7 +190,9 @@ class FileExtractor(Protocol):
) -> dict[str, Any]: ...
@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:
@@ -187,7 +201,9 @@ class Extractor:
format_priorities = {"epub": 1, "pdf": 2}
@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 = {}
# Sort based on file priority
@@ -235,7 +251,7 @@ class Extractor:
metadata = FilepathExtractor.extract_metadata(files[0], root_path) | metadata
# 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:
# "How Linux Works, 3rd Edition: What Every Superuser Should Know" has to
# lose the edition first for the colon count to mean anything.
@@ -282,7 +298,7 @@ class Extractor:
file_ext = get_file_extension(filename)
if file_ext is None:
return float('inf')
return float("inf")
return Extractor.format_priorities.get(file_ext, float("inf"))
@@ -618,7 +634,9 @@ class FilepathExtractor(FileExtractor):
"""Extracts metadata from the filepath."""
@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):
path = Path(input.filename).parent
@@ -633,27 +651,28 @@ class FilepathExtractor(FileExtractor):
if len(parts) == 3:
# Format: Author/Series/Part - Title/filename
metadata['author'] = parts[0]
metadata["author"] = parts[0]
# Extract part number and title from directory name (parts[2])
dirname = parts[2]
match = re.match(r'^([\d.]+)\s*-\s*(.+)$', dirname)
match = re.match(r"^([\d.]+)\s*-\s*(.+)$", dirname)
if match:
metadata['series_position'] = match.group(1) # Keep as string
metadata['series'] = parts[1]
metadata['title'] = match.group(2).strip()
metadata["series_position"] = match.group(1) # Keep as string
metadata["series"] = parts[1]
metadata["title"] = match.group(2).strip()
else:
metadata['series'] = parts[1]
metadata['title'] = path.stem
metadata["series"] = parts[1]
metadata["title"] = path.stem
elif len(parts) == 2:
# Format: Author/Title
metadata['author'] = parts[0]
metadata['title'] = path.stem # Remove extension
metadata["author"] = parts[0]
metadata["title"] = path.stem # Remove extension
return metadata
class FilenameExtractor(FileExtractor):
"""Extracts metadata from the filename."""
+25 -11
View File
@@ -4,25 +4,37 @@ from typing import Any, Literal, Optional, Sequence
from pydantic import BaseModel, Field
from datetime import datetime
class LinkTypes(StrEnum):
NAVIGATION = "application/atom+xml;profile=opds-catalog;kind=navigation"
ACQUISITION = "application/atom+xml;profile=opds-catalog;kind=acquisition"
OPEN_SEARCH = "application/opensearchdescription+xml"
class AcquisitionRelations(StrEnum):
_BASE = "http://opds-spec.org/acquisition"
ACQUISITION = _BASE # A generic relation that indicates that the entry may be retrieved
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
ACQUISITION = (
_BASE # A generic relation that indicates that the entry may be retrieved
)
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
SAMPLE = f"{_BASE}/sample" # 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):
_BASE = ""
class LinkRelations(StrEnum):
"""Link types for OPDSv1.2 related resources
@@ -32,17 +44,19 @@ class LinkRelations(StrEnum):
_BASE = "http://opds-spec.org"
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
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
POPULAR = f"{_BASE}/sort/popular" # Most popular entries
FEATURED = f"{_BASE}/featured" # Entries selected for promotion
RECOMMENDED = f"{_BASE}/recommended" # Entries recommended to the specific user
class Feed(BaseModel): # OPDS Catalog root element
xmlns: Literal["http://www.w3.org/2005/Atom"] = Field(
default="http://www.w3.org/2005/Atom", serialization_alias="@xmlns"
@@ -117,9 +131,8 @@ class AcquisitionFeedLink(Link):
class NavigationFeedLink(Link):
type: str = Field(
default=LinkTypes.NAVIGATION, serialization_alias="@type"
)
type: str = Field(default=LinkTypes.NAVIGATION, serialization_alias="@type")
class Content(BaseModel):
type: Literal["text"] = Field(default="text", serialization_alias="@type")
@@ -170,6 +183,7 @@ class Entry(BaseModel):
data = super().model_dump(**kwargs)
return {"entry": data}
@dataclass
class PaginationResult:
next_link: Optional[Link]
+40 -53
View File
@@ -1,4 +1,3 @@
from typing import Any, Callable, Sequence
from urllib.parse import quote_plus, urlencode
from litestar import Request
@@ -16,9 +15,10 @@ from .models import (
AcquisitionFeedLink,
NavigationFeed,
NavigationFeedLink,
PaginationResult
PaginationResult,
)
def get_opensearch_document(base_url: str = "/opds/search?") -> str:
search = {
"OpenSearchDescription": {
@@ -120,19 +120,20 @@ def create_navigation_feed(
pretty=True,
)
def create_library_navigation_feed(library: m.Library) -> str:
entries = [
Entry(
id=f"/opds/library/{library.id}/all-books",
title='All Books',
title="All Books",
link=[
AcquisitionFeedLink(
rel="subsection",
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",
)
]
],
),
Entry(
id=f"/opds/library/{library.id}/recently-added",
@@ -141,9 +142,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink(
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",
title="Recently Added"
title="Recently Added",
)
]
],
),
Entry(
id=f"/opds/library/{library.id}/shelves",
@@ -152,9 +153,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink(
rel="subsection",
href=f"/opds/library/{library.id}/shelves?paginated=1&pageSize=10",
title="Bookshelves"
title="Bookshelves",
)
]
],
),
Entry(
id=f"/opds/library/{library.id}/tags",
@@ -163,9 +164,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink(
rel="subsection",
href=f"/opds/library/{library.id}/tags?paginated=1&pageSize=10",
title="Tags"
title="Tags",
)
]
],
),
Entry(
id=f"/opds/library/{library.id}/authors",
@@ -174,9 +175,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink(
rel="subsection",
href=f"/opds/library/{library.id}/authors?paginated=1&pageSize=10",
title="Authors"
title="Authors",
)
]
],
),
Entry(
id=f"/opds/library/{library.id}/publishers",
@@ -185,32 +186,32 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink(
rel="subsection",
href=f"/opds/library/{library.id}/publishers?paginated=1&pageSize=10",
title="Publishers"
title="Publishers",
)
]
],
),
]
feed = create_navigation_feed(
id=f'/library/{library.id}',
id=f"/library/{library.id}",
title=library.name,
self_url=f'/opds/library/{library.id}',
links=[
],
entries=entries
self_url=f"/opds/library/{library.id}",
links=[],
entries=entries,
)
return feed
def create_collection_navigation_feed(
library: m.Library,
collection_type: str,
items: Sequence[m.BookList | m.Tag | m.Author | m.Publisher | m.BookSeries],
links: list[Link] = list(),
# 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:
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",
title=get_title(item),
)
]
) for item in items
],
)
for item in items
]
return create_navigation_feed(
id=f"/opds/library/{library.id}/{collection_type}",
title=collection_type.title(),
self_url=f'/opds/library/{library.id}/{collection_type}',
self_url=f"/opds/library/{library.id}/{collection_type}",
entries=entries,
links=links
links=links,
)
def create_next_paginated_link(
request: Request,
total: int,
current_count: int,
offset: int,
feed_title: str
request: Request, total: int, current_count: int, offset: int, feed_title: str
) -> Link | None:
if total <= current_count + offset:
return None
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)}"
return Link(
rel="next",
href=next_url,
title=feed_title,
type=LinkTypes.NAVIGATION
)
return Link(rel="next", href=next_url, title=feed_title, type=LinkTypes.NAVIGATION)
def create_search_link(
request: Request,
exclude_params: set[str] | None = None
request: Request, exclude_params: set[str] | None = None
) -> Link:
"""Create search link with current filters applied"""
if exclude_params is None:
exclude_params = {'currentPage', 'feed_title', 'feed_id', 'search', 'paginated'}
exclude_params = {"currentPage", "feed_title", "feed_id", "search", "paginated"}
params = {
k: v for k, v in request.query_params.items()
if k not in exclude_params
}
params = {k: v for k, v in request.query_params.items() if k not in exclude_params}
return Link(
rel="search",
@@ -277,7 +268,6 @@ def create_search_link(
)
def create_pagination_links(
request: Request,
total: int,
@@ -296,14 +286,11 @@ def create_pagination_links(
params = dict(request.query_params)
# Calculate next page number
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_link = Link(
rel="next",
href=next_url,
title=f"{feed_title} - Next",
type=link_type
rel="next", href=next_url, title=f"{feed_title} - Next", type=link_type
)
# Create previous link if not on first page
@@ -311,14 +298,14 @@ def create_pagination_links(
params = dict(request.query_params)
# Calculate previous page number
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_link = Link(
rel="previous",
href=prev_url,
title=f"{feed_title} - Previous",
type=link_type
type=link_type,
)
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)
async def move_file(src_path: Path, dest_path: Path, create_dirs=True) -> None:
"""
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.
shutil.move(str(src_path), str(dest_path))
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.
+2 -1
View File
@@ -2,6 +2,8 @@ from pathlib import Path
from uuid import uuid4
import pytest
from chitai import services
from advanced_alchemy.base import UUIDAuditBase
from litestar.testing import AsyncTestClient
from sqlalchemy import text
@@ -155,7 +157,6 @@ async def other_authenticated_client(
# Service fixtures
from chitai import services
@pytest.fixture
+43 -34
View File
@@ -1,6 +1,7 @@
import pytest
from httpx import AsyncClient
from pathlib import Path
from litestar.status_codes import HTTP_400_BAD_REQUEST
@pytest.mark.parametrize(
@@ -218,7 +219,9 @@ async def test_list_books_by_id(populated_authenticated_client: AsyncClient) ->
compare a bigint primary key against them. Nothing called it until a screen needed
to fetch a handful of books by id.
"""
response = await populated_authenticated_client.get("/books?ids=1&ids=2&pageSize=10")
response = await populated_authenticated_client.get(
"/books?ids=1&ids=2&pageSize=10"
)
assert response.status_code == 200
assert sorted(book["id"] for book in response.json()["items"]) == [1, 2]
@@ -228,7 +231,7 @@ async def test_get_book_by_id(populated_authenticated_client: AsyncClient) -> No
"""Test retrieving a specific book by ID."""
# Retrieve the book
response = await populated_authenticated_client.get(f"/books/1")
response = await populated_authenticated_client.get("/books/1")
assert response.status_code == 200
book_data = response.json()
@@ -300,13 +303,13 @@ async def test_delete_book_metadata_only(
# Delete book without deleting files
response = await populated_authenticated_client.delete(
f"/books?book_ids=3&delete_files=false&library_id=1"
"/books?book_ids=3&delete_files=false&library_id=1"
)
assert response.status_code == 204
# Verify book is deleted
get_response = await populated_authenticated_client.get(f"/books/3")
get_response = await populated_authenticated_client.get("/books/3")
assert get_response.status_code == 404
@@ -317,7 +320,7 @@ async def test_delete_book_with_files(
# Delete book and files
response = await populated_authenticated_client.delete(
f"/books?book_ids=3&delete_files=true&library_id=1"
"/books?book_ids=3&delete_files=true&library_id=1"
)
assert response.status_code == 204
@@ -330,7 +333,7 @@ async def test_delete_specific_book_files(
# Delete specific file
response = await populated_authenticated_client.delete(
f"/books/1/files?file_ids=1",
"/books/1/files?file_ids=1",
)
assert response.status_code == 204
@@ -347,7 +350,7 @@ async def test_update_reading_progress(
}
response = await populated_authenticated_client.post(
f"/books/progress/1",
"/books/progress/1",
json=progress_data,
)
@@ -423,7 +426,9 @@ async def test_create_books_groups_formats_within_one_folder(
) -> None:
"""Picking a book's own folder yields one book with both formats, not two books."""
epub = Path("tests/data_files/Metamorphosis - Franz Kafka.epub").read_bytes()
pdf = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf").read_bytes()
pdf = Path(
"tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf"
).read_bytes()
files = [
("files", ("Metamorphosis/Metamorphosis.epub", epub, "application/epub+zip")),
@@ -534,7 +539,9 @@ class TestDuplicateHandling:
"files",
(
"war.epub",
Path("tests/data_files/The Art of War - Sun Tzu.epub").read_bytes(),
Path(
"tests/data_files/The Art of War - Sun Tzu.epub"
).read_bytes(),
"application/epub+zip",
),
)
@@ -550,7 +557,9 @@ class TestDuplicateHandling:
"files",
(
"war.epub",
Path("tests/data_files/The Art of War - Sun Tzu.epub").read_bytes(),
Path(
"tests/data_files/The Art of War - Sun Tzu.epub"
).read_bytes(),
"application/epub+zip",
),
)
@@ -736,7 +745,9 @@ class TestDuplicateBooks:
assert len(merged["files"]) == 2
# The folded record is gone, and the group it formed with it.
assert (await authenticated_client.get(f"/books/{fold['id']}")).status_code == 404
assert (
await authenticated_client.get(f"/books/{fold['id']}")
).status_code == 404
groups = await authenticated_client.get("/books/duplicate-books?library_id=1")
assert groups.json() == []
@@ -786,16 +797,6 @@ class TestDuplicateBooks:
# async def test_edit_book_metadata(authenticated_client: AsyncClient) -> None:
# raise NotImplementedError()
import pytest
import aiofiles
from httpx import AsyncClient
from pathlib import Path
from litestar.status_codes import HTTP_400_BAD_REQUEST
import pytest
from httpx import AsyncClient
from datetime import date
class TestMetadataUpdates:
@pytest.mark.parametrize(
@@ -849,8 +850,10 @@ class TestMetadataUpdates:
(
"authors", # Update with new authors
["New Author 1", "New Author 2"],
lambda data: {a["name"] for a in data["authors"]}
== {"New Author 1", "New Author 2"},
lambda data: (
{a["name"] for a in data["authors"]}
== {"New Author 1", "New Author 2"}
),
),
(
"authors", # Clear authors
@@ -860,8 +863,9 @@ class TestMetadataUpdates:
(
"tags", # Update with new tags
["Tag 1", "Tag 2", "Tag 3"],
lambda data: {t["name"] for t in data["tags"]}
== {"Tag 1", "Tag 2", "Tag 3"},
lambda data: (
{t["name"] for t in data["tags"]} == {"Tag 1", "Tag 2", "Tag 3"}
),
),
(
"tags", # Clear tags
@@ -881,8 +885,10 @@ class TestMetadataUpdates:
(
"identifiers", # Update with new identifiers
{"isbn-13": "978-1234567890", "doi": "10.example/id"},
lambda data: data["identifiers"]
== {"isbn-13": "978-1234567890", "doi": "10.example/id"},
lambda data: (
data["identifiers"]
== {"isbn-13": "978-1234567890", "doi": "10.example/id"}
),
),
(
"identifiers", # Clear identifiers
@@ -1053,7 +1059,7 @@ class TestMetadataUpdates:
result = response.json()
assert result[updated_field] == None
assert result[updated_field] is None
@pytest.mark.parametrize(
("updated_field"),
@@ -1215,7 +1221,9 @@ class TestFileManagement:
pytest.skip("Book has no files")
file_id = book_data["files"][0]["id"]
filename = book_data["files"][0].get("path")
# TODO: this test asserts only the 204 and never checks the disk, so the flag it is
# named for is untested. See TODO.md; drop the noqa when the assertion lands.
filename = book_data["files"][0].get("path") # noqa: F841
# Remove file without deleting from filesystem
response = await populated_authenticated_client.delete(
@@ -1240,7 +1248,8 @@ class TestFileManagement:
book_data = add_response.json()
file_id = book_data["files"][-1]["id"]
file_path = book_data["files"][-1].get("path")
# TODO: as above -- the file is never checked for removal from disk.
file_path = book_data["files"][-1].get("path") # noqa: F841
# Remove file with deletion from filesystem
response = await populated_authenticated_client.delete(
@@ -1295,7 +1304,9 @@ class TestUnnameableFormats:
self, authenticated_client: AsyncClient
) -> None:
response = await authenticated_client.post(
"/books?library_id=1", files=self.upload("Dune.mobi"), data={"library_id": 1}
"/books?library_id=1",
files=self.upload("Dune.mobi"),
data={"library_id": 1},
)
assert response.status_code == 201
@@ -1323,9 +1334,7 @@ class TestUnnameableFormats:
assert detail.status_code == 200
assert detail.json()["files"][0]["content_type"] is None
async def test_the_file_downloads(
self, authenticated_client: AsyncClient
) -> None:
async def test_the_file_downloads(self, authenticated_client: AsyncClient) -> None:
"""Litestar supplies its own media type when the row carries none."""
created = await authenticated_client.post(
"/books?library_id=1",
@@ -1,4 +1,3 @@
import pytest
from httpx import AsyncClient
@@ -200,7 +199,6 @@ async def test_remove_books_from_shelf(
"/books", params={"shelves": shelf_id}
)
assert books_response.status_code == 200
assert books_response.json()["total"] == 2
@@ -42,7 +42,9 @@ def fx_source(tmp_path: Path) -> Path:
cover=True,
formats={"EPUB": EPUB},
)
fixture.add_book(2, "The Art of War", authors=["Sun Tzu"], formats={"EPUB": OTHER_EPUB})
fixture.add_book(
2, "The Art of War", authors=["Sun Tzu"], formats={"EPUB": OTHER_EPUB}
)
fixture.add_book(3, "Metadata Only", authors=["Nobody"])
return fixture.commit()
@@ -95,7 +97,8 @@ async def test_an_uploaded_library_imports(
authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None:
status, job = await upload(
authenticated_client, zip_of(source, tmp_path / "out", prefix="Calibre Library/")
authenticated_client,
zip_of(source, tmp_path / "out", prefix="Calibre Library/"),
)
assert status == 202
@@ -278,7 +281,9 @@ async def test_a_second_copy_is_counted_as_a_possible_duplicate(
padded.write_bytes(EPUB.read_bytes() + b"\0" * 64)
fixture = CalibreFixture(tmp_path / "calibre")
fixture.add_book(1, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": EPUB})
fixture.add_book(
1, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": EPUB}
)
fixture.add_book(
2, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": padded}
)
+12 -3
View File
@@ -8,7 +8,9 @@ from pathlib import Path
# Known KOReader hashes for test files
TEST_FILES = {
"Moby Dick; Or, The Whale - Herman Melville.epub": {
"path": Path("tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub"),
"path": Path(
"tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub"
),
"hash": "ceeef909ec65653ba77e1380dff998fb",
"content_type": "application/epub+zip",
},
@@ -59,7 +61,9 @@ async def test_add_file_to_book_generates_correct_hash(
first_book = TEST_FILES["Moby Dick; Or, The Whale - Herman Melville.epub"]
first_content = first_book["path"].read_bytes()
files = [("files", (first_book["path"].name, first_content, first_book["content_type"]))]
files = [
("files", (first_book["path"].name, first_content, first_book["content_type"]))
]
data = {"library_id": "1"}
create_response = await authenticated_client.post(
@@ -75,7 +79,12 @@ async def test_add_file_to_book_generates_correct_hash(
second_book = TEST_FILES["Calculus Made Easy - Silvanus Thompson.pdf"]
second_content = second_book["path"].read_bytes()
add_files = [("data", (second_book["path"].name, second_content, second_book["content_type"]))]
add_files = [
(
"data",
(second_book["path"].name, second_content, second_book["content_type"]),
)
]
add_response = await authenticated_client.post(
f"/books/{book_id}/files",
+1 -1
View File
@@ -40,5 +40,5 @@ async def test_create_library(
assert result["name"] == "Test Library"
assert result["root_path"] == f"{tmp_path}/books"
assert result["path_template"] == "{author}/{title}"
assert result["read_only"] == False
assert result["read_only"] is False
assert result["description"] is None
+22 -7
View File
@@ -2,7 +2,10 @@
from pathlib import Path
from chitai.services.filesystem_library import BookPathGenerator, sanitize_path_component
from chitai.services.filesystem_library import (
BookPathGenerator,
sanitize_path_component,
)
ROOT = Path("/library")
@@ -23,12 +26,15 @@ def test_a_book_with_no_authors() -> None:
def test_a_series_adds_a_level_and_pads_the_position() -> None:
assert path_for(
assert (
path_for(
title="Persepolis Rising",
authors=["James S. A. Corey"],
series="The Expanse",
series_position="7",
) == ROOT / "James S. A. Corey" / "The Expanse" / "07 - Persepolis Rising"
)
== ROOT / "James S. A. Corey" / "The Expanse" / "07 - Persepolis Rising"
)
def test_a_slash_in_a_title_does_not_add_a_directory() -> None:
@@ -43,16 +49,25 @@ def test_a_slash_in_a_title_does_not_add_a_directory() -> None:
generated = path_for(title="Back in Black: AC/DC", authors=["Murray Engleheart"])
assert generated == ROOT / "Murray Engleheart" / "Back in Black: AC_DC"
assert generated.relative_to(ROOT).parts == ("Murray Engleheart", "Back in Black: AC_DC")
assert generated.relative_to(ROOT).parts == (
"Murray Engleheart",
"Back in Black: AC_DC",
)
def test_a_slash_in_an_author_or_series_is_handled_too() -> None:
assert path_for(title="Split", authors=["A/B Collective"]) == (
ROOT / "A_B Collective" / "Split"
)
assert path_for(
title="Volume One", authors=["Someone"], series="Either/Or", series_position="1"
) == ROOT / "Someone" / "Either_Or" / "01 - Volume One"
assert (
path_for(
title="Volume One",
authors=["Someone"],
series="Either/Or",
series_position="1",
)
== ROOT / "Someone" / "Either_Or" / "01 - Volume One"
)
def test_control_characters_are_removed() -> None:
+12 -4
View File
@@ -54,7 +54,8 @@ class TestNormalizeTitle:
assert normalize_title("The") == "the"
@pytest.mark.parametrize(
"title", ["Catch 22", "Fahrenheit 451", "Blade Runner 2049", "1984", "Apollo 13"]
"title",
["Catch 22", "Fahrenheit 451", "Blade Runner 2049", "1984", "Apollo 13"],
)
def test_a_number_is_not_an_edition(self, title: str) -> None:
"""Edition stripping keys on the `e`; a bare number is part of the title."""
@@ -163,8 +164,13 @@ class TestNormalizeIdentifier:
def test_uuids_are_refused(self) -> None:
"""Generated per build, so they only re-find what the hash check catches."""
assert normalize_identifier("uuid", "3f2b1c4e-1111-2222-3333-444455556666") is None
assert normalize_identifier("urn:uuid", "3f2b1c4e-1111-2222-3333-444455556666") is None
assert (
normalize_identifier("uuid", "3f2b1c4e-1111-2222-3333-444455556666") is None
)
assert (
normalize_identifier("urn:uuid", "3f2b1c4e-1111-2222-3333-444455556666")
is None
)
def test_other_schemes_keep_their_own_key(self) -> None:
assert normalize_identifier("asin", "B000FC0PDA") == "asin:b000fc0pda"
@@ -187,7 +193,9 @@ class TestIsbnConversion:
assert isbn10_to_isbn13("043942089X") == "9780439420891"
@pytest.mark.parametrize("isbn", ["0486282113", "9780486282114", "nonsense"])
def test_anything_that_is_not_an_isbn_10_converts_to_nothing(self, isbn: str) -> None:
def test_anything_that_is_not_an_isbn_10_converts_to_nothing(
self, isbn: str
) -> None:
assert isbn10_to_isbn13(isbn) is None
+13 -5
View File
@@ -22,7 +22,6 @@ class TestEpubExtractor:
assert metadata["published_date"] == date(year=2001, month=7, day=1)
EPUB = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
PDF = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf")
@@ -31,7 +30,9 @@ PDF = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf")
class TestIdentifierMerging:
"""A book's formats each contribute identifiers; none of them replaces the rest."""
async def test_every_format_contributes(self, monkeypatch: pytest.MonkeyPatch) -> None:
async def test_every_format_contributes(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""
Identifiers are a collection, not a single value.
@@ -47,7 +48,9 @@ class TestIdentifierMerging:
}
async def pdf(_file):
return {"identifiers": {"isbn-13": "9781718500402", "isbn-10": "1593270356"}}
return {
"identifiers": {"isbn-13": "9781718500402", "isbn-10": "1593270356"}
}
monkeypatch.setattr(EpubExtractor, "extract_metadata", epub)
monkeypatch.setattr(PdfExtractor, "extract_metadata", pdf)
@@ -133,7 +136,9 @@ class TestSplitEdition:
),
],
)
def test_editions_are_split_out(self, title: str, stripped: str, edition: int) -> None:
def test_editions_are_split_out(
self, title: str, stripped: str, edition: int
) -> None:
assert split_edition(title) == (stripped, edition)
@pytest.mark.parametrize(
@@ -171,7 +176,10 @@ class TestEditionFromFiles:
"""The PDF fixture calls itself a 2nd edition in its own metadata title."""
metadata = await Extractor.extract_metadata([PDF])
assert metadata["title"] == "The Project Gutenberg eBook #33283: Calculus Made Easy"
assert (
metadata["title"]
== "The Project Gutenberg eBook #33283: Calculus Made Easy"
)
assert metadata["edition"] == 2
@@ -25,7 +25,9 @@ PDF = DATA_FILES / "Calculus Made Easy - Silvanus Thompson.pdf"
def upload(path: Path, name: str | None = None) -> UploadFile:
"""An uploaded file carrying the bytes of one of the test fixtures."""
return UploadFile(
content_type="application/pdf" if path.suffix == ".pdf" else "application/epub+zip",
content_type="application/pdf"
if path.suffix == ".pdf"
else "application/epub+zip",
filename=name or path.name,
file_data=path.read_bytes(),
)
@@ -538,9 +540,7 @@ class TestBookPathCollisions:
assert original.path != forced.path
paths = {
Path(book.path) / book.files[0].path for book in (original, forced)
}
paths = {Path(book.path) / book.files[0].path for book in (original, forced)}
assert len(paths) == 2
assert all(path.is_file() for path in paths)
@@ -581,7 +581,10 @@ class TestBookPathCollisions:
# Renamed onto the first book's author and title.
await books_service.update_book(
second.books[0].id,
{"title": original.title, "authors": [author.name for author in original.authors]},
{
"title": original.title,
"authors": [author.name for author in original.authors],
},
test_library,
)
@@ -743,7 +746,8 @@ class TestDuplicateBooks:
)
matches = await books_service.find_duplicate_books(
{"title": "Building Microservices", "authors": ["Newman, Sam;"]}, test_library
{"title": "Building Microservices", "authors": ["Newman, Sam;"]},
test_library,
)
assert [match.book_id for match in matches] == [stored.id]
@@ -834,16 +838,22 @@ class TestDuplicateBooks:
"series": "Foundation",
}
assert await books_service.find_duplicate_books(
assert (
await books_service.find_duplicate_books(
incoming | {"series_position": "2"}, test_library
) == []
)
== []
)
# The same volume, written a little differently, still matches.
assert len(
assert (
len(
await books_service.find_duplicate_books(
incoming | {"series_position": "1.0"}, test_library
)
) == 1
)
== 1
)
async def test_a_book_is_not_its_own_duplicate(
self, books_service: BookService, test_library: m.Library
@@ -909,7 +919,10 @@ class TestAuthorNames:
existing row and then collides with it on the unique index.
"""
first = await store_book(
books_service, test_library, title="Building Microservices", authors=["Sam Newman"]
books_service,
test_library,
title="Building Microservices",
authors=["Sam Newman"],
)
second = await store_book(
books_service,
@@ -953,7 +966,9 @@ class TestAuthorNames:
"Franz Kafka.epub" is not a person.
"""
result = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB, "Some Unknown Book - Ada Lovelace.epub")]),
BooksCreateFromFiles(
files=[upload(EPUB, "Some Unknown Book - Ada Lovelace.epub")]
),
test_library,
)
@@ -1014,7 +1029,10 @@ class TestMergeBooks:
) -> None:
"""The survivor keeps its own fields unless the caller says otherwise."""
keep = await store_book(
books_service, test_library, title="Building Microservices", authors=["Sam Newman"]
books_service,
test_library,
title="Building Microservices",
authors=["Sam Newman"],
)
fold = await store_book(
books_service,
@@ -1030,7 +1048,11 @@ class TestMergeBooks:
assert merged.publisher is None
other = await store_book(
books_service, test_library, title="Monolith", authors=["Sam Newman"], edition=3
books_service,
test_library,
title="Monolith",
authors=["Sam Newman"],
edition=3,
)
merged = await books_service.merge_books(
keep.id, [other.id], test_library, metadata={"edition": 3}
@@ -1096,10 +1118,14 @@ class TestMergeBooks:
await books_service.merge_books(keep.id, [fold.id], test_library)
rows = (
(
await books_service.repository.session.execute(
select(m.BookProgress).where(m.BookProgress.book_id == keep.id)
)
).scalars().all()
)
.scalars()
.all()
)
assert [row.percentage for row in rows] == [0.6]
@@ -1112,13 +1138,23 @@ class TestMergeBooks:
) -> None:
"""Both books on one shelf must not leave the survivor linked to it twice."""
keep = await store_book(
books_service, test_library, title="A", authors=["X"], tags=["Shared", "Only Keep"]
books_service,
test_library,
title="A",
authors=["X"],
tags=["Shared", "Only Keep"],
)
fold = await store_book(
books_service, test_library, title="B", authors=["X"], tags=["Shared", "Only Fold"]
books_service,
test_library,
title="B",
authors=["X"],
tags=["Shared", "Only Fold"],
)
shelf = m.BookList(title="Later", user_id=test_user.id, library_id=test_library.id)
shelf = m.BookList(
title="Later", user_id=test_user.id, library_id=test_library.id
)
session.add(shelf)
await session.commit()
session.add_all(
@@ -1131,13 +1167,21 @@ class TestMergeBooks:
merged = await books_service.merge_books(keep.id, [fold.id], test_library)
assert sorted(tag.name for tag in merged.tags) == ["Only Fold", "Only Keep", "Shared"]
assert sorted(tag.name for tag in merged.tags) == [
"Only Fold",
"Only Keep",
"Shared",
]
links = (
(
await books_service.repository.session.execute(
select(m.BookListLink).where(m.BookListLink.book_id == keep.id)
)
).scalars().all()
)
.scalars()
.all()
)
assert len(links) == 1
async def test_an_identifier_moves_only_under_a_name_the_survivor_lacks(
@@ -2,19 +2,12 @@
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from chitai.services import ShelfService
from chitai.database import models as m
import pytest
from sqlalchemy import select
from chitai.services.bookshelf import ShelfService
from chitai.services import BookService
from chitai.database.models.book_list import BookList, BookListLink
from chitai.database import models as m
@pytest.fixture
@@ -201,7 +201,9 @@ async def test_importing_twice_creates_nothing(
]
held_by = [
skipped.book_id for skipped in result.skipped if skipped.reason == "already stored"
skipped.book_id
for skipped in result.skipped
if skipped.reason == "already stored"
]
assert all(book_id is not None for book_id in held_by)
@@ -223,7 +225,9 @@ async def test_a_file_the_catalogue_lists_but_disk_does_not(
await source.close()
assert len(result.created) == 1
assert [(s.calibre_id, s.reason) for s in result.skipped] == [(2, "no files on disk")]
assert [(s.calibre_id, s.reason) for s in result.skipped] == [
(2, "no files on disk")
]
async def test_one_broken_book_does_not_stop_the_import(
@@ -312,7 +316,11 @@ async def test_shared_authors_and_tags_are_one_row_each(
1, "One", authors=["Franz Kafka"], tags=["Fiction"], formats={"EPUB": EPUB}
)
fixture.add_book(
2, "Two", authors=["Franz Kafka"], tags=["Fiction"], formats={"EPUB": OTHER_EPUB}
2,
"Two",
authors=["Franz Kafka"],
tags=["Fiction"],
formats={"EPUB": OTHER_EPUB},
)
root = fixture.commit()
@@ -34,8 +34,8 @@ class TestLibraryServiceCRUD:
assert library.name == "Test Library"
assert library.root_path == library_path
assert library.path_template == "{author}/{title}"
assert library.description == None
assert library.read_only == False
assert library.description is None
assert library.read_only is False
# Check if directory was created
assert Path(library.root_path).is_dir()
@@ -56,8 +56,8 @@ class TestLibraryServiceCRUD:
read_only=False,
)
with pytest.raises(PermissionError) as exc_info:
library = await library_service.create(library_data)
with pytest.raises(PermissionError):
await library_service.create(library_data)
# Check if directory was created
assert not Path(library_path).exists()
@@ -86,8 +86,8 @@ class TestLibraryServiceCRUD:
assert library.name == "Test Library"
assert library.root_path == library_path
assert library.path_template == "{author}/{title}"
assert library.description == None
assert library.read_only == True
assert library.description is None
assert library.read_only is True
async def test_create_library_read_only_nonexistent_path(
self, library_service: LibraryService, tmp_path: Path
@@ -138,7 +138,7 @@ class TestLibraryServiceCRUD:
assert library.root_path == "./books"
assert library.path_template == "{author}/{title}"
assert library.description is None
assert library.read_only == False
assert library.read_only is False
# async def test_delete_library_keep_files(
# self, session: AsyncSession, library_service: LibraryService
@@ -21,7 +21,7 @@ class TestUserServiceAuthentication:
# Create a user with a known password
password = "password123"
user = m.User(email=f"test@example.com", password=password)
user = m.User(email="test@example.com", password=password)
session.add(user)
await session.commit()
@@ -52,7 +52,7 @@ class TestUserServiceAuthentication:
# Create user
password = "password123"
user = m.User(email=f"test@example.com", password=password)
user = m.User(email="test@example.com", password=password)
session.add(user)
await session.commit()
@@ -85,7 +85,7 @@ class TestUserServiceCRUD:
) -> None:
"""Test getting user by email."""
user = m.User(email=f"test@example.com", password="password123")
user = m.User(email="test@example.com", password="password123")
session.add(user)
await session.commit()
@@ -102,12 +102,12 @@ class TestUserServiceCRUD:
"""Test creating a new user with a duplicate email."""
# Create first user
user = m.User(email=f"test@example.com", password="password123")
user = m.User(email="test@example.com", password="password123")
session.add(user)
await session.commit()
# Create second user
user = m.User(email=f"test@example.com", password="password12345")
user = m.User(email="test@example.com", password="password12345")
with pytest.raises(IntegrityError) as exc_info:
session.add(user)
+27
View File
@@ -261,6 +261,7 @@ dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-databases", extra = ["postgres"] },
{ name = "ruff" },
]
[package.metadata]
@@ -286,6 +287,7 @@ dev = [
{ name = "pytest", specifier = ">=8.4.2" },
{ name = "pytest-asyncio", specifier = ">=1.2.0" },
{ name = "pytest-databases", extras = ["postgres"], specifier = ">=0.15.0" },
{ name = "ruff", specifier = "==0.15.14" },
]
[[package]]
@@ -1277,6 +1279,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/e5/d708d262b600a352abe01c2ae360d8ff75b0af819b78e9af293191d928e6/rich_click-1.9.7-py3-none-any.whl", hash = "sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b", size = 71491, upload-time = "2026-01-31T04:29:26.777Z" },
]
[[package]]
name = "ruff"
version = "0.15.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" },
{ url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" },
{ url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" },
{ url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" },
{ url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" },
{ url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" },
{ url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" },
{ url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" },
{ url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" },
{ url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" },
{ url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" },
{ url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" },
{ url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" },
{ url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" },
{ url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" },
]
[[package]]
name = "six"
version = "1.17.0"
+9
View File
@@ -24,6 +24,10 @@ services:
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
networks:
@@ -53,6 +57,8 @@ services:
condition: service_healthy
frontend:
image: git.jaroszew.ski/patrick/chitai-frontend:${CHITAI_VERSION:-latest}
build: ./frontend
networks:
@@ -66,6 +72,9 @@ services:
environment:
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:
backend:
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:
build
coverage
.pytest_cache
# Miscellaneous
/static/
# Vendored third-party source, copied verbatim by scripts/vendor-foliate.sh
/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:
- `pnpm check` is not clean. **Baseline as of 2026-08-13: 30 errors, 1 warning, 8 files**, most of
them in `src/routes/api/[...path]/+server.ts` (see below). Get your own baseline before assuming
an error is yours. `src/lib/schema/openapi/schema.d.ts` was regenerated on that date and is
current; regenerate it again after any backend API change, with
- **`pnpm check` is clean as of 2026-08-17 and CI blocks on it** — 0 errors, 0 warnings. Any error
you see is yours. `src/lib/schema/openapi/schema.d.ts` is
current; regenerate it after any backend API change, with
`pnpm exec openapi-typescript http://localhost:8000/schema/openapi.json -o src/lib/schema/openapi/schema.d.ts`
against a backend running **your** branch — a stale server silently writes a stale file.
- `pnpm lint` does not pass either — 131 pre-existing ESLint errors, 35 of them
`svelte/no-navigation-without-resolve` on plain `href`s, plus ~59 files Prettier would rewrite
(mostly vendored shadcn components). Check the files you touched, not the whole tree.
- **Prettier is clean and CI blocks on it** — run `pnpm format` before finishing. Two things it
must not touch are in `.prettierignore`: the vendored foliate-js, and
`src/lib/schema/openapi/schema.d.ts`, which `openapi-typescript` regenerates in its own style.
- `pnpm exec eslint .` reports **2 errors as of 2026-08-17**, both `svelte/no-at-html-tags` in
`collapsible-text.svelte`. They are a genuine XSS hole, not a lint nit — see `TODO.md`. CI runs
eslint non-blocking (`continue-on-error`) only until that is fixed. `static/pdfjs/` is ignored
alongside `src/lib/vendor/` — it is vendored too, and linting it produced 1717 further errors.
- Two rules are off for `**/*.svelte` in `eslint.config.js` because they predate runes and
misread them: `no-useless-assignment` (every `$bindable()` default) and
`@typescript-eslint/no-unused-expressions` (a bare `book;` declaring an `$effect` dependency).
A leading underscore marks an intentionally unused binding.
- `src/routes/api/[...path]/+server.ts` — all four handlers are annotated `RequestHandler` while the
import of that type is commented out at line 4. It also buffers whole **responses** with
`arrayBuffer()` and forwards no `Range` header, so book downloads are not streamed. **Requests**
+7 -2
View File
@@ -11,7 +11,9 @@ WORKDIR /app
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 \
pnpm fetch --frozen-lockfile
@@ -25,7 +27,7 @@ RUN --mount=type=cache,target=/pnpm/store \
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 \
pnpm install --frozen-lockfile
@@ -54,4 +56,7 @@ EXPOSE 3000
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" ]
+29 -2
View File
@@ -13,7 +13,9 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig(
includeIgnoreFile(gitignorePath),
// 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,
...ts.configs.recommended,
...svelte.configs.recommended,
@@ -26,7 +28,18 @@ export default defineConfig(
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
'no-undef': 'off',
// A leading underscore marks a binding that exists to hold a position — a callback
// parameter the signature requires, or the discarded half of a destructure.
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_'
}
]
}
},
{
@@ -35,6 +48,20 @@ export default defineConfig(
files: ['src/lib/components/ui/**'],
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'],
languageOptions: {
+28 -27
View File
@@ -2,6 +2,7 @@
"name": "chitai-web",
"private": true,
"version": "0.0.1",
"packageManager": "pnpm@11.20.0",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -14,42 +15,42 @@
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@eslint/compat": "^1.4.1",
"@eslint/js": "^9.39.4",
"@iconify/svelte": "^5.2.1",
"@internationalized/date": "^3.12.0",
"@lucide/svelte": "^0.544.0",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.53.4",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.1",
"@types/node": "^22.19.15",
"bits-ui": "^2.16.3",
"@eslint/compat": "^2.1.0",
"@eslint/js": "^10.0.1",
"@iconify/svelte": "^5.2.2",
"@internationalized/date": "^3.12.3",
"@lucide/svelte": "^1.31.0",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.70.2",
"@sveltejs/vite-plugin-svelte": "^7.3.0",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^26.2.0",
"bits-ui": "^2.18.1",
"clsx": "^2.1.1",
"eslint": "^9.39.4",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.15.0",
"globals": "^16.5.0",
"jsrepo": "^2.5.2",
"eslint-plugin-svelte": "^3.23.0",
"globals": "^17.11.0",
"jsrepo": "^3.8.1",
"openapi-typescript": "^7.13.0",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.5.2",
"prettier": "^3.9.6",
"prettier-plugin-svelte": "^4.1.1",
"prettier-plugin-tailwindcss": "^0.8.1",
"svelte": "^5.53.7",
"svelte-check": "^4.4.5",
"tailwind-merge": "^3.5.0",
"svelte": "^5.56.9",
"svelte-check": "^4.7.6",
"tailwind-merge": "^3.6.0",
"tailwind-scrollbar": "^4.0.2",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.2.1",
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1"
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0",
"vite": "^8.2.1"
},
"dependencies": {
"construct-style-sheets-polyfill": "^3.1.0",
"mode-watcher": "^1.1.0",
"svelte-sonner": "^1.0.8",
"zod": "^4.3.6"
"svelte-sonner": "^1.2.1",
"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;
/* 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-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';
export const login = form(loginSchema, async (data, issue) => {
const { cookies, locals } = getRequestEvent();
const { cookies } = getRequestEvent();
// Create URL-encoded form data
const formData = new URLSearchParams();
+21 -11
View File
@@ -1,5 +1,10 @@
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 { error } from '@sveltejs/kit';
@@ -19,7 +24,9 @@ export const listBookshelves = query(bookshelfQuerySchema, async (data) => {
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 params = createQueryParams(data);
@@ -31,10 +38,13 @@ export const addBooksToShelf = command(modifyBooksInShelf, async ({ shelf_id, ..
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 params = createQueryParams(data);
@@ -46,19 +56,19 @@ export const removeBooksFromShelf = command(modifyBooksInShelf, async ({ shelf_i
error(response.status, message);
}
return await response.json()
});
return await response.json();
}
);
export const createBookshelf = command(bookshelfCreate, async (data) => {
const { locals } = getRequestEvent();
const response = await locals.api.post(`/shelves`, data)
const response = await locals.api.post(`/shelves`, data);
if (!response.ok) {
const message = await response.text();
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');
const deviceResult = await response.json();
return deviceResult.items
return deviceResult.items;
});
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');
return await response.json();
})
});
export const deleteDevice = command(z.string(), async (deviceId): Promise<void> => {
const { locals } = getRequestEvent();
+1 -1
View File
@@ -25,6 +25,6 @@ export const createLibrary = form(libraryCreateSchema, async (data) => {
return await response.json();
});
export const deleteLibrary = query('unchecked', async (data) => {
export const deleteLibrary = query('unchecked', async (_data) => {
throw new Error('Not implemented');
});
@@ -3,8 +3,6 @@
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
let {
open = $bindable(),
@@ -13,12 +11,9 @@
}: {
open: boolean;
title?: string;
deleteFn: (deleteFiles: boolean) => {};
deleteFn: (deleteFiles: boolean) => void | Promise<void>;
} = $props();
const selectedState = getBookSelectionState();
const bookOps = getBookOperationsState();
let deleteFiles = $state(false);
</script>
@@ -13,7 +13,7 @@
</script>
<form
{...createDevice.enhance(async ({ form, submit }) => {
{...createDevice.enhance(async ({ element, submit }) => {
try {
await submit();
@@ -24,7 +24,7 @@
const deviceName = createDevice.fields.name.value();
form.reset();
element.reset();
toast.success(`Device '${deviceName}' created`);
onSuccess?.();
} catch (error) {
@@ -39,7 +39,7 @@
<Field.Field>
<Field.Label for="name">Device name</Field.Label>
<Input {...createDevice.fields.name.as('text')} placeholder="e.g. Kindle Paperwhite" />
{#each createDevice.fields.name.issues() ?? [] as issue}
{#each createDevice.fields.name.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -50,10 +50,10 @@
<form
bind:this={formEl}
{...updateBookCover.enhance(async ({ submit, form }) => {
{...updateBookCover.enhance(async ({ submit, element }) => {
try {
await submit();
form.reset();
element.reset();
// Deliberately does not close the dialog. The cover is one panel of a
// larger form now, and closing here would throw away metadata edits
// the reader has not saved yet.
@@ -128,7 +128,7 @@
<Field.Field class="col-span-full">
<Field.Label for="title">Title</Field.Label>
<Input {...updateBookMetadata.fields.title.as('text')} />
{#each updateBookMetadata.fields.title.issues() ?? [] as issue}
{#each updateBookMetadata.fields.title.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -136,7 +136,7 @@
<Field.Field>
<Field.Label for="subtitle">Subtitle</Field.Label>
<Input {...updateBookMetadata.fields.subtitle.as('text')} />
{#each updateBookMetadata.fields.subtitle.issues() ?? [] as issue}
{#each updateBookMetadata.fields.subtitle.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -144,7 +144,7 @@
<Field.Field>
<Field.Label for="edition">Edition</Field.Label>
<Input {...updateBookMetadata.fields.edition.as('number')} />
{#each updateBookMetadata.fields.edition.issues() ?? [] as issue}
{#each updateBookMetadata.fields.edition.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -152,7 +152,7 @@
<Field.Field>
<Field.Label for="series">Series</Field.Label>
<Input {...updateBookMetadata.fields.series.as('text')} />
{#each updateBookMetadata.fields.series.issues() ?? [] as issue}
{#each updateBookMetadata.fields.series.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -161,7 +161,7 @@
<Field.Field>
<Field.Label for="series_position">No.</Field.Label>
<Input {...updateBookMetadata.fields.series_position.as('text')} />
{#each updateBookMetadata.fields.series_position.issues() ?? [] as issue}
{#each updateBookMetadata.fields.series_position.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -169,7 +169,7 @@
<Field.Field>
<Field.Label for="language">Language</Field.Label>
<Input {...updateBookMetadata.fields.language.as('text')} />
{#each updateBookMetadata.fields.language.issues() ?? [] as issue}
{#each updateBookMetadata.fields.language.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -185,10 +185,10 @@
placeholder="Add an author"
class="min-h-10 p-2 text-sm"
/>
{#each authors as author}
{#each authors as author, i (i)}
<input class="hidden" {...updateBookMetadata.fields.authors.as('checkbox', author)} />
{/each}
{#each updateBookMetadata.fields.authors.issues() ?? [] as issue}
{#each updateBookMetadata.fields.authors.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -201,10 +201,10 @@
placeholder="Add a tag"
class="min-h-10 p-2 text-sm"
/>
{#each tags as tag}
{#each tags as tag, i (i)}
<input class="hidden" {...updateBookMetadata.fields.tags.as('checkbox', tag)} />
{/each}
{#each updateBookMetadata.fields.tags.issues() ?? [] as issue}
{#each updateBookMetadata.fields.tags.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -214,7 +214,7 @@
<Field.Field>
<Field.Label for="publisher">Publisher</Field.Label>
<Input {...updateBookMetadata.fields.publisher.as('text')} />
{#each updateBookMetadata.fields.publisher.issues() ?? [] as issue}
{#each updateBookMetadata.fields.publisher.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -223,7 +223,7 @@
<Field.Field>
<Field.Label for="published_date">Published</Field.Label>
<Input {...updateBookMetadata.fields.published_date.as('date')} />
{#each updateBookMetadata.fields.published_date.issues() ?? [] as issue}
{#each updateBookMetadata.fields.published_date.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -231,7 +231,7 @@
<Field.Field>
<Field.Label for="pages">Pages</Field.Label>
<Input {...updateBookMetadata.fields.pages.as('number')} />
{#each updateBookMetadata.fields.pages.issues() ?? [] as issue}
{#each updateBookMetadata.fields.pages.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -240,7 +240,7 @@
<Field.Field class="col-span-full">
<Field.Label for="identifiers">Identifiers</Field.Label>
<div class="flex flex-col gap-2">
{#each identifierKeys as _, idx}
{#each identifierKeys as _, idx (idx)}
<div class="grid grid-cols-[1fr_1.6fr_auto] gap-2">
<Input bind:value={identifierKeys[idx]} placeholder="ISBN, DOI…" />
<Input bind:value={identifierValues[idx]} placeholder="Value" />
@@ -271,7 +271,7 @@
<Field.Field class="col-span-full">
<Field.Label for="description" class="sr-only">Description</Field.Label>
<Textarea {...updateBookMetadata.fields.description.as('text')} rows={6} />
{#each updateBookMetadata.fields.description.issues() ?? [] as issue}
{#each updateBookMetadata.fields.description.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -23,7 +23,7 @@
</Dialog.Header>
<form
{...createLibrary.enhance(async ({ form, submit }) => {
{...createLibrary.enhance(async ({ element, submit }) => {
try {
await submit();
@@ -35,7 +35,7 @@
const libraryName = createLibrary.fields.name.value();
form.reset();
element.reset();
selectedIcon = 'library';
open = false;
toast.success(`Library '${libraryName}' created.`);
@@ -56,7 +56,7 @@
<Field.Field>
<Field.Label for="name">Library name</Field.Label>
<Input {...createLibrary.fields.name.as('text')} />
{#each createLibrary.fields.name.issues() ?? [] as issue}
{#each createLibrary.fields.name.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -73,7 +73,7 @@
<Field.Field>
<Field.Label for="description">Description</Field.Label>
<Textarea {...createLibrary.fields.description.as('text')} />
{#each createLibrary.fields.description.issues() ?? [] as issue}
{#each createLibrary.fields.description.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -82,7 +82,7 @@
<Field.Field>
<Field.Label for="root_path">Root Path</Field.Label>
<Input {...createLibrary.fields.root_path.as('text')} />
{#each createLibrary.fields.root_path.issues() ?? [] as issue}
{#each createLibrary.fields.root_path.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
<Field.Description class="text-xs">
@@ -94,7 +94,7 @@
<Field.Field>
<Field.Label for="path_template">Path Template</Field.Label>
<Input {...createLibrary.fields.path_template.as('text')} />
{#each createLibrary.fields.path_template.issues() ?? [] as issue}
{#each createLibrary.fields.path_template.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
<Field.Description class="text-xs">
@@ -21,7 +21,7 @@
<Field.Field>
<Field.Label for="email">Email</Field.Label>
<Input {...login.fields.email.as('email')} />
{#each login.fields.email.issues() ?? [] as issue}
{#each login.fields.email.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -30,7 +30,7 @@
<Field.Field>
<Field.Label for="password">Password</Field.Label>
<Input {...login.fields.password.as('password')} />
{#each login.fields.password.issues() ?? [] as issue}
{#each login.fields.password.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -1,15 +1,16 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/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('')
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/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('');
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
@@ -17,7 +17,7 @@
</Card.Header>
<form
{...signup.enhance(async ({ submit, form }) => {
{...signup.enhance(async ({ submit, element }) => {
try {
await submit();
@@ -29,7 +29,7 @@
// Move to login tab on success
// TODO: Fix previous errors showing on login form
form.reset();
element.reset();
toast.success('Successfully registered!');
login.fields.set({ email: '', password: '' });
login.validate();
@@ -47,7 +47,7 @@
<Field.Field>
<Field.Label for="email">Email</Field.Label>
<Input {...signup.fields.email.as('email')} />
{#each signup.fields.email.issues() ?? [] as issue}
{#each signup.fields.email.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -56,7 +56,7 @@
<Field.Field>
<Field.Label for="password">Password</Field.Label>
<Input {...signup.fields.password.as('password')} />
{#each signup.fields.password.issues() ?? [] as issue}
{#each signup.fields.password.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -65,7 +65,7 @@
<Field.Field>
<Field.Label for="confirmPassword">Confirm Password</Field.Label>
<Input {...signup.fields.confirmPassword.as('password')} />
{#each signup.fields.confirmPassword.issues() ?? [] as issue}
{#each signup.fields.confirmPassword.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -28,7 +28,9 @@
// directly in the markup keeps it static.
const header = $derived({
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>
@@ -2,8 +2,8 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import { Badge } from "$lib/components/ui/badge/index.js";
import { getLibraryState, LibraryState } from '$lib/state/library.svelte';
import { Badge } from '$lib/components/ui/badge/index.js';
import { getLibraryState } from '$lib/state/library.svelte';
import { LIBRARY_ICONS } from '$lib/components/ui/icon-picker/index.js';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import PlusIcon from '@lucide/svelte/icons/plus';
@@ -12,7 +12,9 @@
const libraryState = getLibraryState();
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>
<Sidebar.Menu>
@@ -35,7 +37,7 @@
<span class="ml-1 truncate font-semibold">
{libraryState.activeLibrary!.name}
</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
</span>
</div>
@@ -51,15 +53,15 @@
>
<DropdownMenu.Label class="text-xs text-muted-foreground">Libraries</DropdownMenu.Label>
{#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">
<div class="flex size-6 items-center justify-center rounded-md border">
<LibraryIcon class="size-3.5 shrink-0" />
</div>
{library.name}
<Badge
variant="outline"
class="font-semibold ml-auto">
<Badge variant="outline" class="ml-auto font-semibold">
{library.total ?? 0}
</Badge>
</DropdownMenu.Item>
@@ -53,9 +53,9 @@
}
}
function handleClick(e: any) {
function handleClick(e: Event & { currentTarget: HTMLElement }) {
open = true;
e.target.blur();
e.currentTarget.blur();
}
</script>
@@ -93,7 +93,7 @@
</div>
{:else}
<Command.List class="max-h-[600px]">
{#if searchResult?.items.length > 0}
{#if (searchResult?.items?.length ?? 0) > 0}
<Command.Group heading="Books">
{#each searchResult?.items as book (book.id)}
<Command.Item
@@ -121,7 +121,7 @@
{#if book.authors.length > 0}
<span class="line-clamp-1 w-full text-sm text-muted-foreground">
by
{#each book.authors as author}
{#each book.authors as author (author.id)}
<a
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
libraryId: String(libraryState.activeLibrary!.id)
@@ -132,7 +132,7 @@
</span>
{/if}
<div class="mt-1 ml-[-1.5] flex">
{#each book.tags as tag}
{#each book.tags as tag (tag.id)}
<Badge class="scale-75">{tag.name}</Badge>
{/each}
</div>
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -14,10 +14,10 @@
bind:this={ref}
data-slot="avatar-badge"
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",
"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=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
'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=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',
className
)}
{...restProps}
@@ -1,6 +1,6 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
bind:ref
data-slot="avatar-fallback"
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
)}
{...restProps}
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -14,7 +14,7 @@
bind:this={ref}
data-slot="avatar-group-count"
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
)}
{...restProps}
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -14,7 +14,7 @@
bind:this={ref}
data-slot="avatar-group"
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
)}
{...restProps}
@@ -1,6 +1,6 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -12,6 +12,6 @@
<AvatarPrimitive.Image
bind:ref
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}
/>
@@ -1,15 +1,15 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
loadingStatus = $bindable("loading"),
size = "default",
loadingStatus = $bindable('loading'),
size = 'default',
class: className,
...restProps
}: AvatarPrimitive.RootProps & {
size?: "default" | "sm" | "lg";
size?: 'default' | 'sm' | 'lg';
} = $props();
</script>
@@ -19,7 +19,7 @@
data-slot="avatar"
data-size={size}
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
)}
{...restProps}
@@ -1,9 +1,9 @@
import Badge from "./avatar-badge.svelte";
import Fallback from "./avatar-fallback.svelte";
import GroupCount from "./avatar-group-count.svelte";
import Group from "./avatar-group.svelte";
import Image from "./avatar-image.svelte";
import Root from "./avatar.svelte";
import Badge from './avatar-badge.svelte';
import Fallback from './avatar-fallback.svelte';
import GroupCount from './avatar-group-count.svelte';
import Group from './avatar-group.svelte';
import Image from './avatar-image.svelte';
import Root from './avatar.svelte';
export {
Root,
@@ -18,5 +18,5 @@ export {
Fallback as AvatarFallback,
Badge as AvatarBadge,
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',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
accent:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90'
accent: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90'
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
@@ -185,7 +185,6 @@
type="file"
onchange={change}
webkitdirectory={directory}
{directory}
class="hidden"
/>
</label>
@@ -6,9 +6,7 @@ import type { WithChildren } from 'bits-ui';
import type { HTMLInputAttributes } from 'svelte/elements';
export type FileRejectedReason =
| 'Maximum file size exceeded'
| 'File type not allowed'
| 'Maximum files uploaded';
'Maximum file size exceeded' | 'File type not allowed' | 'Maximum files uploaded';
export type FileDropZonePropsWithoutHTML = WithChildren<{
ref?: HTMLInputElement | null;
@@ -37,7 +37,7 @@
</script>
<Popover.Root bind:open>
<Popover.Trigger asChild>
<Popover.Trigger>
{#snippet child({ props })}
<Button variant="outline" size="icon" class="h-9 w-9" {...props}>
<SelectedIconComponent class="size-4" />
@@ -45,11 +45,7 @@
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-64 p-3" align="start">
<Input
bind:value={search}
placeholder="Search icons..."
class="mb-3 h-8"
/>
<Input bind:value={search} placeholder="Search icons..." class="mb-3 h-8" />
<ScrollArea class="h-48">
<div class="grid grid-cols-6 gap-1">
{#each filteredIcons as [key, icon] (key)}
@@ -58,7 +54,10 @@
<Tooltip.Trigger>
<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)}
>
<IconComponent class="size-4" />
@@ -59,7 +59,10 @@ import type { Component } from 'svelte';
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
library: { component: Library, label: 'Library', category: 'Generic' },
'book-open': { component: BookOpen, label: 'Book Open', category: 'Generic' },
@@ -1,8 +1,8 @@
import Root from "./popover.svelte";
import Close from "./popover-close.svelte";
import Content from "./popover-content.svelte";
import Trigger from "./popover-trigger.svelte";
import Portal from "./popover-portal.svelte";
import Root from './popover.svelte';
import Close from './popover-close.svelte';
import Content from './popover-content.svelte';
import Trigger from './popover-trigger.svelte';
import Portal from './popover-portal.svelte';
export {
Root,
@@ -15,5 +15,5 @@ export {
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose,
Portal as PopoverPortal,
Portal as PopoverPortal
};
@@ -1,5 +1,5 @@
<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();
</script>
@@ -1,14 +1,14 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
import PopoverPortal from "./popover-portal.svelte";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import { Popover as PopoverPrimitive } from 'bits-ui';
import PopoverPortal from './popover-portal.svelte';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
align = "center",
align = 'center',
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
@@ -23,7 +23,7 @@
{sideOffset}
{align}
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
)}
{...restProps}
@@ -1,5 +1,5 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
import { Popover as PopoverPrimitive } from 'bits-ui';
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
</script>
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Popover as PopoverPrimitive } from "bits-ui";
import { cn } from '$lib/utils.js';
import { Popover as PopoverPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
@@ -12,6 +12,6 @@
<PopoverPrimitive.Trigger
bind:ref
data-slot="popover-trigger"
class={cn("", className)}
class={cn('', className)}
{...restProps}
/>
@@ -1,5 +1,5 @@
<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();
</script>
@@ -36,7 +36,6 @@
$effect(() => {
// whenever input value changes reset invalid
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
inputValue;
untrack(() => {

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