Compare commits

...
17 Commits
Author SHA1 Message Date
patrick 510306f24d fix: keep embedded metadata and group formats on directory upload
The filepath extractor was merged on the right, so a parent folder's name
overrode the title inside the file. Grouping also split a book's formats when
its own folder was the upload root.
2026-08-12 15:17:48 -04:00
patrick bd8d68b9ba fix: resolve file paths and stream the multi-book zip download
file.path is relative to book.path, so the archive matched nothing on disk and
came out empty. Also namespace entries per book to stop filename collisions, and
stream the zip instead of buffering it whole.
2026-08-12 15:17:48 -04:00
patrick 540522e828 fix: reuse existing link rows when updating book relationships
Assigning through the association proxy recreated links for targets the book
already had, colliding with the link tables' unique constraints. Reconcile the
collections in place instead, identifiers included.
2026-08-12 15:17:48 -04:00
patrick a1f39a8dc8 fix: scope book progress and shelves to the requesting user 2026-08-11 20:08:26 -04:00
patrick dfeb4c3267 chore: remove unused book files accordion component 2026-08-11 00:32:44 -04:00
patrick e4ecfad5dd chore: regenerate OpenAPI types 2026-08-11 00:27:56 -04:00
patrick 3a3957d432 fix: resolve BookProgressRead reference in BookRead 2026-08-11 00:27:56 -04:00
patrick 23a12f7970 docs: add AGENTS.md for repo, backend and frontend 2026-08-10 23:58:42 -04:00
patrick c3e337de32 fix: correct book browser scroll area height 2026-08-10 23:58:42 -04:00
patrick 3ae3dcb0d0 fix: send correct field names when updating book progress 2026-08-10 23:58:42 -04:00
patrick 3091cc879d fix: skip unreadable PDFs during metadata extraction 2026-08-10 23:58:42 -04:00
patrick 1415cb6244 fix: preserve directory structure when uploading books 2026-08-10 23:58:42 -04:00
patrick cc39e79cd7 chore: regenerate initial migrations 2026-08-10 23:58:09 -04:00
patrick c5b703b75a refactor: use unique constraints instead of composite keys on link tables 2026-08-10 23:58:09 -04:00
patrick 9fe69641c5 feat: add library icons 2026-08-10 23:58:02 -04:00
patrick 0963ee85c2 chore: update nix dev shells and pnpm build settings 2026-08-10 23:58:02 -04:00
patrick 448e0e0090 chore: ignore local library files 2026-08-10 23:57:46 -04:00
45 changed files with 1386 additions and 345 deletions
+103
View File
@@ -0,0 +1,103 @@
# Chitai
Self-hosted eBook library manager. Users organise eBook files into **libraries**, which contain
**books** (one book = one metadata record + one or more files on disk). Books carry authors,
publishers, tags, series and identifiers, can be grouped into per-user **bookshelves**, and are read
in-browser through built-in EPUB and PDF readers with reading-progress tracking. The catalogue is
also exposed as an **OPDS** feed for e-reader apps, and progress syncs with **KOReader** devices via
a KOSync-compatible endpoint.
## Layout
| Path | What |
| --- | --- |
| `backend/` | Litestar REST API + PostgreSQL. See `backend/AGENTS.md`. |
| `frontend/` | SvelteKit SSR web app. See `frontend/AGENTS.md`. |
| `docker-compose.yml` | Production stack: `db` (postgres:17), `backend`, `frontend`. |
| `docs/screenshots/` | Images used by `README.md`. |
| `shell.nix` | Root dev shell; composes the two sub-shells. |
## Development environment
`nix-shell` from the repo root is the intended entry point. It pulls in both
`backend/shell.nix` and `frontend/shell.nix`, whose `shellHook`s do real work as a side effect:
- **backend** — `uv venv` + `uv sync`, then `initdb` / `pg_ctl start` into `backend/.postgres/`
(socket dir, not TCP), `createdb chitai`, and finally applies migrations with
`alchemy --config chitai.database.config.config upgrade --no-prompt`.
`exitHook` stops PostgreSQL on shell exit.
- **frontend** — `pnpm install`.
So entering the shell gives you a running database with an up-to-date schema; you do not need to
start Postgres yourself. Both hooks `cd` around, which can be surprising in scripts.
Without Nix you need: Python 3.13 + `uv`, Node 24 + `pnpm`, and PostgreSQL 17 with the `pg_trgm`
extension available.
## Configuration
All backend settings are read by `backend/src/chitai/config.py` (`Settings`, pydantic-settings) with
the **`CHITAI_` prefix** from the repo-root `.env`. `.env.prod-example` is the template; copy it to
`.env` for a fresh deployment.
`.env` is gitignored and contains a real `CHITAI_TOKEN_SECRET` — do not print, copy or commit it.
The frontend reads exactly one variable, `VITE_BACKEND_API_URL`
(`frontend/src/lib/server/config.ts`, defaulting to `http://localhost:8000`).
## How a request flows
```
browser
└─ SvelteKit SSR node server
├─ hooks.server.ts reads `authToken` cookie, validates via GET /access/me,
│ populates locals.user / locals.api, redirects to /login otherwise
├─ $lib/api/*.remote.ts remote functions (query/command/form) → locals.api (ApiClient)
└─ routes/api/[...path] catch-all proxy, for browser-direct fetches (reader file streams)
└─ Litestar backend
└─ controllers/ → services/ → SQLAlchemy models → PostgreSQL
```
The JWT the frontend holds in the `authToken` cookie is the same bearer token the backend issues
from `POST /access/login`. The frontend never stores credentials beyond that cookie.
## Commands
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/
alchemy --config chitai.database.config.config make-migrations
alchemy --config chitai.database.config.config upgrade
```
Frontend (from `frontend/`):
```bash
pnpm dev # vite dev server on :5173
pnpm build # adapter-node output in build/
pnpm check # svelte-check — run before finishing
pnpm lint # prettier --check + eslint
pnpm format # prettier --write
```
API docs are served by the running backend at `http://localhost:8000/schema/` (Swagger) and
`/schema/openapi.json`.
## Cross-cutting rules
- **Keep the two schema layers in sync.** Backend request/response shapes live in
`backend/src/chitai/schemas/` (Pydantic); the frontend mirrors them as Zod schemas in
`frontend/src/lib/schema/`. Changing one without the other produces runtime validation failures,
not type errors.
- **Regenerate the OpenAPI types** after changing API shapes.
`frontend/src/lib/schema/openapi/schema.d.ts` is generated from the backend's OpenAPI document
with `openapi-typescript` (a devDependency; there is no `package.json` script for it, so it is run
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.
- **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.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+1
View File
@@ -12,4 +12,5 @@ wheels/
# Project specific directories/files
covers/
books/
libraries/
.postgres/
+151
View File
@@ -0,0 +1,151 @@
# Chitai backend
Litestar REST API for the eBook library. See the repo-root `AGENTS.md` for the overall picture and
dev-environment setup.
**Stack:** Python 3.13 · Litestar 2 · advanced-alchemy over async SQLAlchemy 2 · asyncpg ·
PostgreSQL 17 · Alembic (through advanced-alchemy's `alchemy` CLI) · pydantic-settings · uv.
## Layering
```
controllers/ HTTP surface only — parse, delegate, serialise. Keep thin.
services/ Business logic. One SQLAlchemyAsyncRepositoryService subclass per aggregate.
database/models/ SQLAlchemy models.
schemas/ Pydantic DTOs for request bodies and responses.
```
Controllers call **service** methods, not the repository. `app.py` assembles the app: route
handlers, JWT auth, exception handlers, the SQLAlchemy plugin, and two lifespan context managers.
## advanced-alchemy idioms
These are the conventions that are easy to get wrong if you write plain SQLAlchemy here:
- Models extend `BigIntAuditBase` (adds id/created_at/updated_at); pure link and child rows extend
`BigIntBase` (e.g. `Identifier`, `FileMetadata`, `BookAuthorLink`).
- A service declares an inner repository and points at it:
```python
class BookService(SQLAlchemyAsyncRepositoryService[Book]):
class Repo(SQLAlchemyAsyncRepository[Book]):
model_type = Book
repository_type = Repo
```
- Transform incoming data with the **`to_model_on_create` / `to_model_on_update` hooks**, not by
overriding `create`/`update` wholesale — see `services/book.py:407` onward.
- Serialise responses through `service.to_schema(obj, schema_type=s.SomeRead)`; for lists,
`to_schema(items, total, filters, schema_type=…)` produces the `OffsetPagination` envelope.
- `Author`, `Tag`, `Publisher` and `BookSeries` are deduplicated with **`as_unique_async`**. Never
construct them directly when attaching to a book — use
`await Author.as_unique_async(session, name=name)` as
`BookService._populate_with_unique_relationships` does, or you will create duplicate rows.
- Domain methods on services are named for the domain (`create_book`, `update_book`, `add_files`),
deliberately distinct from the inherited CRUD names.
## Dependency injection
`services/dependencies.py` is the hub; controllers wire providers in their `dependencies` dict.
- Most providers come from `create_service_provider(SomeService, …)` — one line each.
- `provide_book_service` is hand-written because it must inject eager loads *and* scope
user-specific rows: `selectinload` for authors/tags/files/etc., plus `with_loader_criteria` so
`BookProgress` and `BookListLink` only load rows belonging to `current_user`. If you add a
relationship that the API returns, add it to that `load` list.
- `create_book_filter_dependencies` intentionally **overrides** advanced-alchemy's stock providers:
the search filter becomes a trigram search, and order-by gains a `random` sort order. Do not
replace it with the stock `create_filter_dependencies`.
- `get_library_by_id` resolves the target library from either a `library_id` query param or the
book's own `library_id`, and raises 404 for either miss.
## Filters
`services/filters/` holds `StatementFilter` dataclasses that compose into any `list` /
`list_and_count` call: `TagFilter`, `AuthorFilter`, `BookshelfFilter`, `ProgressFilter`,
`TrigramSearchFilter`, `CustomOrderBy`, `FileHashFilter`, plus the `*LibraryFilter` variants used by
OPDS.
To add list-filtering behaviour: write the dataclass here, add a `provide_*_filter` function in
`dependencies.py`, register it in the controller's `dependencies`, and fold it into
`provide_book_filters` — the controller handler itself does not change.
## Authentication — three schemes
| Scheme | Where | Used by |
| --- | --- | --- |
| JWT bearer (`OAuth2PasswordBearerAuth`) | `app.py` | The web frontend and the main API. Public paths are listed in its `exclude=[…]`. |
| HTTP Basic (`middleware/basic_auth.py`) | `OpdsController` | E-reader / OPDS clients, which only speak Basic. |
| `X-AUTH-USER` API key (`middleware/kosync_auth.py`) | `KosyncController` | KOReader devices; the key maps to a `KosyncDevice` row, which maps to a user. |
Each middleware resolves a `User` onto the connection; the matching
`provide_user_via_basic_auth` / `provide_user_via_kosync_auth` dependencies expose it to handlers.
## Filesystem behaviour
The backend owns files on disk, not just rows:
- **Layout** — `services/filesystem_library.py` (`BookPathGenerator`) renders a Jinja2 template
against book metadata to decide where a book lives under the library's `root_path`
(default: `author/series/position - title/`).
- **Metadata extraction** — `services/metadata_extractor.py` reads EPUB (ebooklib) and PDF
(pypdfium2) files; extracted values fill only *empty* fields on the incoming payload.
- **Covers** — converted to WebP with a UUID filename under `settings.book_cover_path`, served by a
static-files router mounted at `/covers`.
- **Consume directory** — `services/consume.py` (`ConsumeDirectoryWatcher`) watches
`settings.consume_path` with `watchfiles`, creates one subdirectory per library slug, batches
additions (3 s debounce) and imports them via `BookService.create_many_from_existing_files`.
Started as an asyncio task from the `setup_directory_watcher` lifespan hook.
- **Updates move files.** `BookService.update_book` regenerates the path from the new metadata and,
if it differs, moves the directory contents and prunes empty parents. Keep that in mind before
changing metadata handling.
## KOReader hashing
`services/utils.py` reimplements KOReader's partial-MD5 document identifier: 1 KiB samples at
offsets produced by LuaJIT's 32-bit `bit.lshift`, including its shift-masking wrap-around
(`shift & 0x1F`, so `i=-1` yields offset 0). `_lshift32` looks wrong and is not — the overflow is
what makes hashes match real devices. Don't "simplify" it; `tests/integration/test_file_hash.py`
guards the behaviour.
## Database
- **`pg_trgm` is required.** `Book.__table_args__` declares a GIN trigram index on `title`, which
`TrigramSearchFilter` uses for fuzzy title search. The extension is enabled by a migration and, in
tests, by `conftest.py`.
- Migrations live in `migrations/versions/`. Generate with
`alchemy --config chitai.database.config.config make-migrations` (add `--no-autogenerate` for a
blank revision), apply with `… upgrade`. `database/config.py` sets `create_all=False`, so nothing
is auto-created at runtime; production applies migrations from `entrypoint.sh`.
- Sessions use `expire_on_commit=False` and Litestar's `before_send_handler="autocommit"`, so a
handler that returns 2xx commits automatically.
## Testing
`pytest tests/` — `asyncio_mode = "auto"`, so async tests need no marker.
- `tests/unit/` exercises services directly against a real session; `tests/integration/` drives the
whole app through `AsyncTestClient(app=create_app())`.
- The database is a throwaway container from `pytest-databases`, so **Docker must be running**.
- `tests/conftest.py` provides the shared fixtures: `client`, `authenticated_client`,
`other_authenticated_client` (a second user, for access-control tests), one fixture per service,
`test_user` / `test_library`, and an autouse fixture that redirects cover storage into `tmp_path`.
- `tests/integration/conftest.py` monkeypatches the module-level alchemy `config` onto the test
engine/sessionmaker and drops+recreates+reseeds the schema for every test.
- Real EPUB and PDF fixtures live in `tests/data_files/`.
## Known rough edges
Observed in the current tree — don't mistake these for intentional patterns to copy:
- `controllers/book.py` — file-level TODO: `book_id` is a path parameter on some endpoints and a
query parameter on others. `set_book_progress_batch` does a documented N+1 (one select + one
upsert per book).
- `services/filesystem_library.py` — TODO to replace Jinja2 templating with simple placeholders;
`generate_filename` accepts a `filename_template` but currently ignores it and returns the
original filename.
- `app.py` — `watcher_task` is declared as a module-level global but assigned locally inside
`setup_directory_watcher`, so the global is never populated (cancellation still works via the
closure).
- `BookService.get_files` (the multi-book ZIP download) opens `Path(file.path)`, but `file.path` is
stored relative to `book.path` — worth verifying before relying on that endpoint.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
@@ -1,8 +1,8 @@
"""Add pg_trgm extension
"""add postgres extensions
Revision ID: 26022ec86f32
Revision ID: 65aa95e8f8cf
Revises:
Create Date: 2025-10-31 18:45:55.027462
Create Date: 2026-03-14 11:59:55.364307
"""
@@ -11,7 +11,12 @@ from typing import TYPE_CHECKING
import sqlalchemy as sa
from alembic import op
from advanced_alchemy.types import EncryptedString, EncryptedText, GUID, ORA_JSONB, DateTimeUTC, StoredObject, PasswordHash
from advanced_alchemy.types import EncryptedString, EncryptedText, GUID, ORA_JSONB, DateTimeUTC, StoredObject, PasswordHash, FernetBackend
from advanced_alchemy.types.encrypted_string import PGCryptoBackend
from advanced_alchemy.types.password_hash.argon2 import Argon2Hasher
from advanced_alchemy.types.password_hash.passlib import PasslibHasher
from advanced_alchemy.types.password_hash.pwdlib import PwdlibHasher
from pwdlib.hashers.argon2 import Argon2Hasher as PwdlibArgon2Hasher
from sqlalchemy import Text # noqa: F401
if TYPE_CHECKING:
@@ -26,17 +31,22 @@ sa.EncryptedString = EncryptedString
sa.EncryptedText = EncryptedText
sa.StoredObject = StoredObject
sa.PasswordHash = PasswordHash
sa.Argon2Hasher = Argon2Hasher
sa.PasslibHasher = PasslibHasher
sa.PwdlibHasher = PwdlibHasher
sa.FernetBackend = FernetBackend
sa.PGCryptoBackend = PGCryptoBackend
# revision identifiers, used by Alembic.
revision = '26022ec86f32'
revision = '65aa95e8f8cf'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(sa.text('create EXTENSION if not EXISTS "pgcrypto"'))
op.execute(sa.text('create EXTENSION if not EXISTS "pg_trgm"'))
op.execute(sa.text('CREATE EXTENSION IF NOT EXISTS "pgcrypto"'))
op.execute(sa.text('CREATE EXTENSION IF NOT EXISTS "pg_trgm"'))
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=UserWarning)
with op.get_context().autocommit_block():
@@ -1,8 +1,8 @@
"""create initial tables
Revision ID: 43bdf42a4f6c
Revises:
Create Date: 2026-03-08 16:00:54.727868
Revision ID: 6d72d1bbc0ee
Revises: 65aa95e8f8cf
Create Date: 2026-03-14 14:36:43.529584
"""
@@ -38,8 +38,8 @@ sa.FernetBackend = FernetBackend
sa.PGCryptoBackend = PGCryptoBackend
# revision identifiers, used by Alembic.
revision = '43bdf42a4f6c'
down_revision = None
revision = '6d72d1bbc0ee'
down_revision = '65aa95e8f8cf'
branch_labels = None
depends_on = None
@@ -61,6 +61,61 @@ def downgrade() -> None:
def schema_upgrades() -> None:
"""schema upgrade migrations go here."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('authors',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTimeUTC(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTimeUTC(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_authors'))
)
with op.batch_alter_table('authors', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_authors_name'), ['name'], unique=True)
op.create_table('book_series',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTimeUTC(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTimeUTC(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_book_series'))
)
with op.batch_alter_table('book_series', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_book_series_title'), ['title'], unique=True)
op.create_table('libraries',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('root_path', sa.String(), nullable=False),
sa.Column('path_template', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('icon', sa.String(), nullable=False),
sa.Column('read_only', sa.Boolean(), nullable=False),
sa.Column('slug', sa.String(length=100), nullable=False),
sa.Column('created_at', sa.DateTimeUTC(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTimeUTC(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_libraries')),
sa.UniqueConstraint('name', name=op.f('uq_libraries_name')),
sa.UniqueConstraint('slug', name='uq_libraries_slug')
)
with op.batch_alter_table('libraries', schema=None) as batch_op:
batch_op.create_index('ix_libraries_slug_unique', ['slug'], unique=True)
op.create_table('publishers',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTimeUTC(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTimeUTC(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_publishers'))
)
op.create_table('tags',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_tags'))
)
with op.batch_alter_table('tags', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_tags_name'), ['name'], unique=True)
op.create_table('users',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
sa.Column('email', sa.String(), nullable=False),
@@ -124,7 +179,8 @@ def schema_upgrades() -> None:
sa.Column('position', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['author_id'], ['authors.id'], name=op.f('fk_book_author_links_author_id_authors')),
sa.ForeignKeyConstraint(['book_id'], ['books.id'], name=op.f('fk_book_author_links_book_id_books'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', 'book_id', 'author_id', name=op.f('pk_book_author_links'))
sa.PrimaryKeyConstraint('id', name=op.f('pk_book_author_links')),
sa.UniqueConstraint('book_id', 'author_id', name=op.f('uq_book_author_links_book_id'))
)
op.create_table('book_list_links',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
@@ -133,7 +189,8 @@ def schema_upgrades() -> None:
sa.Column('position', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['book_id'], ['books.id'], name=op.f('fk_book_list_links_book_id_books'), ondelete='cascade'),
sa.ForeignKeyConstraint(['list_id'], ['book_lists.id'], name=op.f('fk_book_list_links_list_id_book_lists')),
sa.PrimaryKeyConstraint('id', 'book_id', 'list_id', name=op.f('pk_book_list_links'))
sa.PrimaryKeyConstraint('id', name=op.f('pk_book_list_links')),
sa.UniqueConstraint('book_id', 'list_id', name=op.f('uq_book_list_links_book_id'))
)
op.create_table('book_progress',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
@@ -159,7 +216,8 @@ def schema_upgrades() -> None:
sa.Column('position', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['book_id'], ['books.id'], name=op.f('fk_book_tag_link_book_id_books'), ondelete='cascade'),
sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], name=op.f('fk_book_tag_link_tag_id_tags')),
sa.PrimaryKeyConstraint('id', 'book_id', 'tag_id', name=op.f('pk_book_tag_link'))
sa.PrimaryKeyConstraint('id', name=op.f('pk_book_tag_link')),
sa.UniqueConstraint('book_id', 'tag_id', name=op.f('uq_book_tag_link_book_id'))
)
op.create_table('file_metadata',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
@@ -177,7 +235,8 @@ def schema_upgrades() -> None:
sa.Column('book_id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
sa.Column('value', sa.String(), nullable=False),
sa.ForeignKeyConstraint(['book_id'], ['books.id'], name=op.f('fk_identifiers_book_id_books'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', 'name', 'book_id', name=op.f('pk_identifiers'))
sa.PrimaryKeyConstraint('id', name=op.f('pk_identifiers')),
sa.UniqueConstraint('name', 'book_id', name=op.f('uq_identifiers_name'))
)
op.create_table('kosync_progress',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
@@ -213,6 +272,23 @@ def schema_downgrades() -> None:
op.drop_table('books')
op.drop_table('book_lists')
op.drop_table('users')
with op.batch_alter_table('tags', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_tags_name'))
op.drop_table('tags')
op.drop_table('publishers')
with op.batch_alter_table('libraries', schema=None) as batch_op:
batch_op.drop_index('ix_libraries_slug_unique')
op.drop_table('libraries')
with op.batch_alter_table('book_series', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_book_series_title'))
op.drop_table('book_series')
with op.batch_alter_table('authors', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_authors_name'))
op.drop_table('authors')
# ### end Alembic commands ###
def data_upgrades() -> None:
+1
View File
@@ -10,6 +10,7 @@ pkgs.mkShell {
# postgres database
postgresql
claude-code
];
shellHook = ''
+4 -3
View File
@@ -5,8 +5,9 @@ from advanced_alchemy.extensions.litestar import (
AsyncSessionConfig,
SQLAlchemyPlugin,
)
from advanced_alchemy.base import BigIntAuditBase
from sqlalchemy.ext.asyncio import create_async_engine
from chitai.database import models
from chitai.database import models # noqa: F401 # Import to register models
DATABASE_URL = str(settings.postgres_uri)
@@ -16,8 +17,8 @@ config = SQLAlchemyAsyncConfig(
engine_instance=create_async_engine(DATABASE_URL, echo=settings.postgres_echo),
session_config=session_config,
before_send_handler="autocommit",
create_all=True,
create_all=False,
metadata=BigIntAuditBase.registry.metadata,
)
alchemy = SQLAlchemyPlugin(config=config)
+4 -5
View File
@@ -1,7 +1,7 @@
from typing import TYPE_CHECKING, Optional
from collections.abc import Hashable
from sqlalchemy import ColumnElement, ForeignKey
from sqlalchemy import ColumnElement, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import relationship
@@ -35,11 +35,10 @@ class Author(BigIntAuditBase, UniqueMixin):
class BookAuthorLink(BigIntBase):
__tablename__ = "book_author_links"
__table_args__ = (UniqueConstraint("book_id", "author_id"),)
book_id: Mapped[int] = mapped_column(
ForeignKey("books.id", ondelete="cascade"), primary_key=True
)
author_id: Mapped[int] = mapped_column(ForeignKey("authors.id"), primary_key=True)
book_id: Mapped[int] = mapped_column(ForeignKey("books.id", ondelete="cascade"))
author_id: Mapped[int] = mapped_column(ForeignKey("authors.id"))
position: Mapped[int]
+4 -6
View File
@@ -1,8 +1,7 @@
from datetime import date
from typing import TYPE_CHECKING, Any, Optional
from sqlalchemy import Index
from sqlalchemy import ForeignKey
from sqlalchemy import Index, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import relationship
@@ -127,11 +126,10 @@ class Book(BigIntAuditBase):
class Identifier(BigIntBase):
__tablename__ = "identifiers"
__table_args__ = (UniqueConstraint("name", "book_id"),)
name: Mapped[str] = mapped_column(primary_key=True)
book_id: Mapped[int] = mapped_column(
ForeignKey("books.id", ondelete="cascade"), primary_key=True
)
name: Mapped[str]
book_id: Mapped[int] = mapped_column(ForeignKey("books.id", ondelete="cascade"))
value: Mapped[str]
def __repr__(self):
@@ -1,5 +1,5 @@
from typing import Optional
from sqlalchemy import ForeignKey
from sqlalchemy import ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy
from sqlalchemy.ext.orderinglist import ordering_list
@@ -38,10 +38,10 @@ class BookList(BigIntAuditBase):
class BookListLink(BigIntBase):
__tablename__ = "book_list_links"
book_id: Mapped[int] = mapped_column(
ForeignKey("books.id", ondelete="cascade"), primary_key=True
)
list_id: Mapped[int] = mapped_column(ForeignKey("book_lists.id"), primary_key=True)
__table_args__ = (UniqueConstraint("book_id", "list_id"),)
book_id: Mapped[int] = mapped_column(ForeignKey("books.id", ondelete="cascade"))
list_id: Mapped[int] = mapped_column(ForeignKey("book_lists.id"))
position: Mapped[int]
book: Mapped[Book] = relationship(back_populates="list_links")
@@ -17,6 +17,7 @@ class Library(BigIntAuditBase, SlugKey):
# Which structure to save the files in the filesystem (i.e {author_name}/{title}.{ext})
path_template: Mapped[str]
description: Mapped[Optional[str]]
icon: Mapped[str] = mapped_column(default="library")
read_only: Mapped[bool] = mapped_column(nullable=False, default=False)
books: Mapped[list["Book"]] = relationship(back_populates="library")
+4 -7
View File
@@ -1,7 +1,7 @@
from collections.abc import Hashable
from typing import TYPE_CHECKING
from sqlalchemy import ColumnElement, ForeignKey
from sqlalchemy import ColumnElement, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import relationship
@@ -35,15 +35,12 @@ class Tag(BigIntBase, UniqueMixin):
class BookTagLink(BigIntBase):
__tablename__ = "book_tag_link"
__table_args__ = (UniqueConstraint("book_id", "tag_id"),)
book_id: Mapped[int] = mapped_column(
ForeignKey("books.id", ondelete="cascade"), primary_key=True
)
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
book_id: Mapped[int] = mapped_column(ForeignKey("books.id", ondelete="cascade"))
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"))
position: Mapped[int]
book: Mapped["Book"] = relationship(back_populates="tag_links")
tag: Mapped[Tag] = relationship()
+10 -8
View File
@@ -38,6 +38,16 @@ class FileMetadataRead(BaseModel):
return Path(self.path).name
class BookProgressRead(BaseModel):
percentage: float
epub_cfi: str | None = None
epub_xpointer: str | None = None
pdf_page: int | None = None
completed: bool | None = False
device_type: str | None = None
device_id: str | None = None
class BookRead(BaseModel):
id: int
library_id: int
@@ -170,11 +180,3 @@ class BookProgressCreate(BaseModel):
device_id: str | None = None
class BookProgressRead(BaseModel):
percentage: float
epub_cfi: str | None = None
epub_xpointer: str | None = None
pdf_page: int | None = None
completed: bool | None = False
device_type: str | None = None
device_id: str | None = None
+3
View File
@@ -8,6 +8,7 @@ class LibraryCreate(BaseModel):
root_path: str
path_template: str | None = "{author}/{title}"
description: str | None = None
icon: str = "library"
read_only: bool = False
@computed_field
@@ -21,6 +22,7 @@ class LibraryRead(BaseModel):
root_path: str
path_template: str
description: str | None
icon: str
read_only: bool
total: int | None = None
@@ -30,4 +32,5 @@ class LibraryUpdate(BaseModel):
root_path: str | None
path_template: str | None
description: str | None
icon: str | None
read_only: bool | None
+178 -24
View File
@@ -5,7 +5,8 @@ from __future__ import annotations
from collections import defaultdict
import mimetypes
from io import BytesIO
from collections.abc import Callable
from io import BytesIO, RawIOBase
from pathlib import Path
import uuid
import zipfile
@@ -33,6 +34,8 @@ from chitai.config import settings
from chitai.database.models import (
Book,
Author,
BookAuthorLink,
BookTagLink,
Tag,
Publisher,
BookSeries,
@@ -54,6 +57,43 @@ from chitai.services.utils import (
)
class _ZipStream(RawIOBase):
"""
A write-only sink that hands whatever `ZipFile` writes back to the caller.
`ZipFile` wants a file object, but a download handler wants chunks it can yield.
This collects the bytes `ZipFile` produces so the generator driving it can drain
them as they appear, instead of building the whole archive first. Reporting
itself as unseekable makes `ZipFile` emit data descriptors rather than seeking
back to patch entry headers.
"""
def __init__(self) -> None:
self._chunks: list[bytes] = []
self._position = 0
def writable(self) -> bool:
return True
def seekable(self) -> bool:
return False
def tell(self) -> int:
return self._position
def write(self, data: Any) -> int:
chunk = bytes(data)
self._chunks.append(chunk)
self._position += len(chunk)
return len(chunk)
def drain(self) -> bytes:
"""Take everything written since the last drain."""
data = b"".join(self._chunks)
self._chunks.clear()
return data
class BookService(SQLAlchemyAsyncRepositoryService[Book]):
"""Book service for managing book operations."""
@@ -112,15 +152,24 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
if not data.files:
raise ValueError("Must upload at least one file")
# Group book files if they are within the same nested directory
books = defaultdict(list)
# Group the files that make up a single book.
#
# `file.filename` carries the browser's webkitRelativePath, whose first
# component is whichever folder the user picked. Depth is therefore relative
# to the selection, not to the library: picking a book's own folder and
# picking the shelf above it submit the same files at different depths.
#
# Inside a subdirectory the parent directory is unambiguously the book. Sitting
# directly in the selected folder it is genuinely ambiguous — "Fluent Python.epub"
# alongside "Fluent Python.pdf" is one book in two formats, while "a.epub"
# alongside "b.epub" is two books — so the filename stem breaks the tie.
books: dict[tuple[Path, str], list[UploadFile]] = defaultdict(list)
for file in data.files:
filepath = Path(file.filename)
# Books within the root directory should be treated as separate books
if len(filepath.parent.parts) > 1:
books[filepath.parent].append(file)
books[(filepath.parent, "")].append(file)
else:
books[filepath].append(file)
books[(filepath.parent, filepath.stem)].append(file)
return [
await self.create_book(
@@ -281,7 +330,10 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
"""
Get all selected book files as a compressed zip file.
Streams the zip file in chunks to avoid loading the entire file into memory.
Streams the zip file in chunks to avoid loading the entire archive into memory.
Each book gets its own directory in the archive, so two books that happen to
share a filename do not overwrite one another. Files that have a row but no
longer exist on disk are skipped.
Args:
book_ids: List of book IDs to include in the zip.
@@ -292,21 +344,40 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
"""
books = await self.list(Book.id.in_(book_ids), Book.library_id == library_id)
files = [file for book in books for file in book.files]
buffer = BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
for file in files:
path = Path(file.path)
if path.exists():
zip_file.write(path, arcname=path.name)
buffer.seek(0)
stream = _ZipStream()
chunk_size = 32768 # 32 KiB
while True:
chunk = buffer.read(chunk_size)
if not chunk:
break
with zipfile.ZipFile(stream, "w", zipfile.ZIP_DEFLATED) as zip_file:
for book in books:
# Books without files have no path, and `file.path` is stored relative
# to it, so there is nothing to resolve against.
if book.path is None:
continue
book_path = Path(book.path)
for file in book.files:
path = book_path / file.path
if not await aios.path.isfile(path):
continue
# Namespaced by the book's own directory to avoid collisions.
info = zipfile.ZipInfo.from_file(
path, arcname=f"{book_path.name}/{Path(file.path).name}"
)
info.compress_type = zipfile.ZIP_DEFLATED
with zip_file.open(info, "w") as entry:
async with aiofiles.open(path, "rb") as source:
while content := await source.read(chunk_size):
entry.write(content)
if chunk := stream.drain():
yield chunk
if chunk := stream.drain():
yield chunk
# The central directory, written when ZipFile closes.
if chunk := stream.drain():
yield chunk
async def update_book(
@@ -475,10 +546,16 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
model_data = await super().to_model(data)
if "authors" in data:
model_data.authors = [
authors = [
await Author.as_unique_async(self.repository.session, name=author)
for author in data["authors"]
]
self._sync_link_collection(
model_data.author_links,
authors,
"author",
lambda author: BookAuthorLink(author=author),
)
if "series" in data:
if data["series"]:
@@ -497,19 +574,96 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
model_data.publisher = None
if "tags" in data:
model_data.tags = [
tags = [
await Tag.as_unique_async(self.repository.session, name=tag)
for tag in data["tags"]
]
self._sync_link_collection(
model_data.tag_links,
tags,
"tag",
lambda tag: BookTagLink(tag=tag),
)
if "identifiers" in data:
model_data.identifiers = data["identifiers"]
self._sync_identifiers(model_data, data["identifiers"])
if "files" in data:
model_data.files = data["files"]
return model_data
@staticmethod
def _sync_link_collection(
links: list[Any],
targets: list[Any],
attr: str,
creator: Callable[[Any], Any],
) -> None:
"""
Reconcile an association-proxy link collection against the desired targets.
Assigning through the association proxy runs its ``creator`` for every target,
so a target that is already attached gets a brand new link row while the old
one is orphaned. SQLAlchemy flushes a mapper's INSERTs before its DELETEs, so
the new row hits the ``(book_id, tag_id)`` / ``(book_id, author_id)`` unique
constraint while its predecessor is still in the table. Reusing the existing
link keeps that from happening, and holds on to its id.
Args:
links: The link collection to reconcile in place.
targets: The entities the collection should end up pointing at.
attr: Name of the attribute on a link that holds the target.
creator: Builds a new link for a target that is not attached yet.
"""
existing = {getattr(link, attr): link for link in links}
reconciled: list[Any] = []
seen: set[Any] = set()
for target in targets:
if target in seen:
continue
seen.add(target)
reconciled.append(existing.get(target) or creator(target))
# Slice assignment so ordering_list renumbers `position` from the new order.
links[:] = reconciled
@staticmethod
def _sync_identifiers(book: Book, identifiers: Any) -> None:
"""
Reconcile a book's identifiers against the incoming ones, keyed by name.
Same hazard as `_sync_link_collection`: `Identifier` is unique on
``(name, book_id)``, so replacing the collection wholesale re-inserts a name
the book already carries. Existing rows are updated in place instead.
Args:
book: The book whose identifiers are being reconciled.
identifiers: The incoming `Identifier` instances.
"""
if not all(isinstance(identifier, Identifier) for identifier in identifiers):
book.identifiers = identifiers
return
existing = {identifier.name: identifier for identifier in book.identifiers}
reconciled: list[Identifier] = []
seen: set[str] = set()
for incoming in identifiers:
if incoming.name in seen:
continue
seen.add(incoming.name)
current = existing.get(incoming.name)
if current is None:
reconciled.append(incoming)
else:
current.value = incoming.value
reconciled.append(current)
book.identifiers[:] = reconciled
async def _save_book_files(self, library: Library, data: dict) -> list[FileMetadata]:
"""
Save uploaded book files to the filesystem.
+31 -25
View File
@@ -14,7 +14,7 @@ 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, with_loader_criteria
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from litestar import Request
from litestar.params import Dependency, Parameter
@@ -51,8 +51,19 @@ from chitai.services.filters.book import (
async def provide_book_service(
db_session: AsyncSession, current_user: m.User | None = None
db_session: AsyncSession, current_user: m.User = Dependency(skip_validation=True)
) -> AsyncGenerator[BookService, None]:
"""
Provide a BookService with per-user data scoped to the caller.
`current_user` is a required dependency, not an optional argument. It used to
default to None with the scoping below wrapped in `if current_user:` — and
when it was not injected, that block was silently skipped, so
`Book.progress_records` and `Book.list_links` loaded *every* user's rows.
`Book.progress` returns `progress_records[0]`, so one user could see another
user's reading position; the shelf checkboxes leaked the same way. Failing
loudly on a missing user is the point of the change.
"""
load = [
selectinload(m.Book.author_links).selectinload(m.BookAuthorLink.author),
selectinload(m.Book.tag_links).selectinload(m.BookTagLink.tag),
@@ -60,31 +71,26 @@ async def provide_book_service(
m.Book.files,
m.Book.identifiers,
m.Book.series,
# Reading progress, restricted to the caller.
#
# The restriction lives on the relationship via .and_() rather than in a
# separate with_loader_criteria(). advanced_alchemy's
# get_abstract_loader_options() keeps only _AbstractLoad,
# InstrumentedAttribute, RelationshipProperty and "*" entries and drops
# everything else — and with_loader_criteria() is none of those, so the
# previous criteria were discarded before reaching a query. A
# selectinload() carrying its own .and_() survives that filter.
selectinload(
m.Book.progress_records.and_(m.BookProgress.user_id == current_user.id)
),
# Bookshelf membership, restricted to the caller
selectinload(
m.Book.list_links.and_(
m.BookListLink.book_list.has(m.BookList.user_id == current_user.id)
)
).selectinload(m.BookListLink.book_list),
]
# Load in specific user-book data
if current_user:
# Load progress data
load.extend(
[
selectinload(m.Book.progress_records),
with_loader_criteria(
m.BookProgress, m.BookProgress.user_id == current_user.id
),
]
)
# Load shelf data
load.extend(
[
selectinload(m.Book.list_links).selectinload(m.BookListLink.book_list),
with_loader_criteria(
m.BookListLink,
m.Book.lists.any(m.BookList.user_id == current_user.id),
),
]
)
provider_func = create_service_provider(
BookService,
load=load,
@@ -67,8 +67,11 @@ class Extractor:
for file in files:
metadata = FilenameExtractor.extract_metadata(file) | metadata
# Get metadata from filepath
metadata = metadata | FilepathExtractor.extract_metadata(files[0], root_path)
# Get metadata from filepath. Kept on the left so that anything the file
# itself declared outranks a guess made from its directory names — a folder
# called "Fluent Python - Luciano Ramalho" must not overwrite the title the
# EPUB already carries.
metadata = FilepathExtractor.extract_metadata(files[0], root_path) | metadata
# format the title
if metadata.get('title', None):
@@ -155,7 +158,11 @@ class PdfExtractor(FileExtractor):
if isinstance(data, UploadFile):
data = data.file
doc = pypdfium2.PdfDocument(data)
try:
doc = pypdfium2.PdfDocument(data)
except Exception as e:
logger.error(f"Error extracting metadata from pdf: {e}")
return {}
basic_metadata = doc.get_metadata_dict(skip_empty=False)
metadata["title"] = basic_metadata["Title"]
+62
View File
@@ -369,6 +369,68 @@ async def test_create_multiple_books_from_directory(
assert len(data.get("items") or data.get("data")) >= 1
async def test_create_books_from_parent_directory_keeps_embedded_title(
authenticated_client: AsyncClient,
) -> None:
"""A folder name in the upload path must not override the file's own metadata.
The browser sends webkitRelativePath, so picking the shelf above a book's folder
submits one more path component than picking the folder itself. That extra level
used to make the directory name win over the title inside the EPUB.
"""
source = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
files = [
(
"files",
(
"Shelf/Metamorphosis - Franz Kafka/Metamorphosis.epub",
source.read_bytes(),
"application/epub+zip",
),
)
]
response = await authenticated_client.post(
"/books/fromFiles?library_id=1", files=files, data={"library_id": 1}
)
assert response.status_code == 201
books = response.json()["items"]
assert len(books) == 1
assert books[0]["title"] == "Metamorphosis"
async def test_create_books_groups_formats_within_one_folder(
authenticated_client: AsyncClient,
) -> 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()
files = [
("files", ("Metamorphosis/Metamorphosis.epub", epub, "application/epub+zip")),
("files", ("Metamorphosis/Metamorphosis.pdf", pdf, "application/pdf")),
]
response = await authenticated_client.post(
"/books/fromFiles?library_id=1", files=files, data={"library_id": 1}
)
assert response.status_code == 201
books = response.json()["items"]
assert len(books) == 1
assert len(books[0]["files"]) == 2
# NOTE: the multi-book ZIP download is covered at the service level, in
# tests/unit/test_services/test_book_service.py. Driving `/books/download` through
# AsyncTestClient hangs in fixture teardown: it is the only `Stream` endpoint in the
# app, and the test transport never sends the `http.disconnect` that Litestar's
# streaming response waits on, so the app's lifespan shutdown never completes.
# async def test_delete_book_metadata(authenticated_client: AsyncClient) -> None:
# raise NotImplementedError()
@@ -1,5 +1,9 @@
"""Tests for BookService"""
import zipfile
from io import BytesIO
from pathlib import Path
import pytest
import aiofiles.os as aios
@@ -67,3 +71,134 @@ class TestBookServiceCRUD:
assert updated_book.publisher.name == "Tolkien Estate"
assert len(updated_book.tags) == 2
async def test_update_book_reuses_existing_links(
self, books_service: BookService, test_library: m.Library
) -> None:
"""Re-submitting a relationship a book already has must not duplicate its link.
The link tables are unique on (book_id, tag_id) / (book_id, author_id), and
identifiers on (name, book_id), so replacing a collection wholesale used to
insert a row that collided with the one it was replacing.
"""
book_data = BookCreate(
library_id=1,
title="The Two Towers",
authors=["J.R.R Tolkien"],
tags=["Fantasy"],
identifiers={"isbn-13": "9780261102358"},
pages=352,
)
book = await books_service.to_model_on_create(book_data.model_dump())
assert isinstance(book, m.Book)
# Matches what the default template generates, so these updates move nothing.
book.path = f"{test_library.root_path}/J.R.R Tolkien/The Two Towers"
await aios.makedirs(book.path) # type: ignore[arg-type]
books_service.repository.session.add(book)
await books_service.repository.session.commit()
await books_service.repository.session.refresh(book)
# Through the service, so the link collections come back eagerly loaded.
created_book = await books_service.get(book.id)
original_tag_link_id = created_book.tag_links[0].id
# Every collection resubmitted unchanged.
await books_service.update_book(
book.id,
{
"authors": ["J.R.R Tolkien"],
"tags": ["Fantasy"],
"identifiers": {"isbn-13": "9780261102358"},
},
test_library,
)
updated_book = await books_service.get(book.id)
assert [tag.name for tag in updated_book.tags] == ["Fantasy"]
assert [author.name for author in updated_book.authors] == ["J.R.R Tolkien"]
assert len(updated_book.identifiers) == 1
# The existing link is reused, not deleted and reinserted.
assert updated_book.tag_links[0].id == original_tag_link_id
# Keeping one tag while adding another.
await books_service.update_book(
book.id, {"tags": ["Fantasy", "Adventure"]}, test_library
)
updated_book = await books_service.get(book.id)
assert [tag.name for tag in updated_book.tags] == ["Fantasy", "Adventure"]
# Dropping one while keeping the other.
await books_service.update_book(book.id, {"tags": ["Adventure"]}, test_library)
updated_book = await books_service.get(book.id)
assert [tag.name for tag in updated_book.tags] == ["Adventure"]
# A name the book already carries has its value updated in place.
await books_service.update_book(
book.id, {"identifiers": {"isbn-13": "9780261102999"}}, test_library
)
updated_book = await books_service.get(book.id)
assert len(updated_book.identifiers) == 1
assert updated_book.identifiers[0].value == "9780261102999"
async def test_get_files_zips_every_book_file(
self, books_service: BookService, test_library: m.Library
) -> None:
"""The multi-book download must contain the real bytes of each file.
`file.path` holds a bare filename relative to `book.path`, so resolving it on
its own matched nothing on disk and yielded a valid but empty archive.
"""
contents = {
"Dracula.epub": b"epub-payload-" * 512,
"Dracula.pdf": b"pdf-payload-" * 512,
}
book_data = BookCreate(
library_id=test_library.id,
title="Dracula",
authors=["Bram Stoker"],
pages=418,
)
book = await books_service.to_model_on_create(book_data.model_dump())
assert isinstance(book, m.Book)
book.path = f"{test_library.root_path}/Bram Stoker/Dracula"
await aios.makedirs(book.path) # type: ignore[arg-type]
for name, payload in contents.items():
Path(book.path, name).write_bytes(payload)
book.files.append(
m.FileMetadata(
path=name,
size=len(payload),
hash=f"hash-{name}",
content_type=None,
)
)
books_service.repository.session.add(book)
await books_service.repository.session.commit()
archive_bytes = b"".join(
[
chunk
async for chunk in books_service.get_files([book.id], test_library.id)
]
)
with zipfile.ZipFile(BytesIO(archive_bytes)) as archive:
assert archive.testzip() is None
names = archive.namelist()
assert len(names) == len(contents)
# Entries are namespaced by the book's directory, so two books sharing a
# filename cannot overwrite one another.
assert {Path(name).parent.name for name in names} == {"Dracula"}
for name, payload in contents.items():
entry = next(n for n in names if Path(n).name == name)
assert archive.read(entry) == payload
+115
View File
@@ -0,0 +1,115 @@
# Chitai frontend
SvelteKit web app for the eBook library. See the repo-root `AGENTS.md` for the overall picture and
dev-environment setup.
**Stack:** SvelteKit 2 with `adapter-node` · Svelte 5 (runes) · Tailwind v4 · Zod v4 ·
`epubjs` · `mode-watcher` (dark mode) · `svelte-sonner` (toasts) · pnpm.
Two experimental flags are on in `svelte.config.js` and the codebase depends on both:
`kit.experimental.remoteFunctions` and `compilerOptions.experimental.async` (`await` in components).
Tailwind v4 has **no config file** — the theme, oklch colour tokens and `@custom-variant dark` all
live in `src/app.css`.
## Talking to the backend
There are two mechanisms; pick deliberately.
**1. Remote functions — the default.** `src/lib/api/*.remote.ts` export `query` / `command` / `form`
functions from `$app/server`. Each takes a Zod schema from `$lib/schema` as its validator and runs
on the server, reaching the API through `locals.api` (the `ApiClient` in `src/lib/server/api.ts`):
```ts
export const getBook = query(stringCoerce, async (id): Promise<Book> => {
const { locals } = getRequestEvent();
const response = await locals.api.get(`/books/${id}`);
if (!response.ok) error(response.status === 404 ? 404 : 500, '…');
return await response.json();
});
```
Conventions: build query strings with `createQueryParams` from `$lib/utils`; on a failed response
throw SvelteKit's `error(status, message)`; multipart uploads go through `postMultipart` /
`putMultipart`. Re-export new modules from `src/lib/api/index.ts`.
**2. The catch-all proxy** at `src/routes/api/[...path]/+server.ts` forwards GET/POST/PATCH/DELETE
to the backend with the auth header attached. Use it only where the **browser itself** must fetch
the backend — e.g. streaming a book file into the EPUB/PDF reader. It is not the general-purpose
path.
## Auth
`src/hooks.server.ts` is a `sequence` of two handles: the first reads the `authToken` cookie,
constructs an `ApiClient`, validates it with `GET /access/me` and fills `locals.user` /
`locals.authToken` / `locals.api` (clearing the cookie if invalid); the second redirects any route
outside `/login` to the login page when there is no user.
The cookie is set in `src/lib/api/auth.remote.ts` (`login`) — httpOnly, secure, sameSite strict, one
week — and deleted by `logout`. The backend JWT never reaches client-side JS.
## Schemas
- `src/lib/schema/*.ts` — hand-written Zod schemas, used as remote-function input validators and as
the source of the exported TS types. Mirror `backend/src/chitai/schemas/` when the API changes.
- `src/lib/schema/common.ts` — shared building blocks: `stringCoerce` / `arrayCoerce` coercion
helpers, `PaginatedResponse<T>`, and the pagination / search / order query schemas that most list
endpoints compose from.
- `src/lib/schema/openapi/schema.d.ts`**generated** from the backend's OpenAPI document with
`openapi-typescript`. Never hand-edit; regenerate after backend API changes.
## State
Client state lives in classes in `src/lib/state/*.svelte.ts` using `$state` / `$derived`, shared via
Svelte context with a module-level `Symbol` key and a `setXState` / `getXState` pair:
```ts
const LIBRARY_KEY = Symbol('LIBRARY');
export function setLibraryState(libraries: Library[]) { return setContext(LIBRARY_KEY, new LibraryState(libraries)); }
export function getLibraryState() { return getContext<ReturnType<typeof setLibraryState>>(LIBRARY_KEY); }
```
Follow that pattern rather than introducing stores. `library.svelte.ts` is the reference — including
its optimistic-delete-with-rollback and toast handling. `bookCollection` / `bookSelection` /
`bookOperations` split list data, selection and mutations across three cooperating classes.
## Components
- `src/lib/components/ui/` — vendored shadcn-svelte (`components.json`) plus jsrepo blocks from
`@ieedan/shadcn-svelte-extras` (`jsrepo.json`). Treat as generated: add components with the CLIs
rather than hand-writing them, and prefer wrapping over editing.
- App components live in `forms/`, `layout/`, `view/` (browser, grid/list/table, filters, sort) and
`reader/` (epub reader + chapter sidebar).
- `cn()` from `$lib/utils` merges Tailwind classes; the `WithElementRef` / `WithoutChild` helpers
there are the shadcn prop-typing conventions.
## Routing
Route groups carry the layout structure:
- `(root)` — the authenticated shell (sidebar, header); `+layout.server.ts` loads the libraries.
- `(root)/(library)` — library-scoped pages: `library/[libraryId]/view`, `book/[bookId]`, edit, and
the readers at `book/[bookId]/read/{epub,pdf}/[fileId]`.
- `+layout@.svelte` breakouts reset to the root layout for the login page and the full-screen reader.
## Conventions
Prettier (`.prettierrc`): tabs, single quotes, no trailing commas, 100 columns, with the Svelte and
Tailwind plugins. Run `pnpm check` (svelte-check) and `pnpm lint` before considering work done.
## Known rough edges
Observed in the current tree — don't mistake these for intentional patterns to copy:
- `src/lib/schema/openapi/schema.d.ts` is **stale** — it predates the `BookProgress` rework and has
no `BookProgressRead`, so `book.progress` types as `{}`. This is the source of most of the ~104
errors `pnpm check` reports on a clean tree; regenerating it should clear them. Get a baseline
before assuming an error is yours.
- `src/routes/api/[...path]/+server.ts` — all four handlers are annotated `RequestHandler` while the
import of that type is commented out at line 4.
- `src/app.d.ts``App.Locals["user"]` is typed from `lucide-svelte`'s `User` *icon* component
rather than the `User` interface in `$lib/server/auth`.
- Uncommitted work in progress (as of 2026-08-10): library icons, spanning
`components/ui/icon-picker/`, the newly vendored `components/ui/popover/`,
`forms/library-create-form.svelte`, `layout/library-switcher.svelte` and `schema/library.ts`.
Prefer not to refactor those files mid-flight.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+4
View File
@@ -1,3 +1,7 @@
allowBuilds:
core-js: true
es5-ext: true
esbuild: true
onlyBuiltDependencies:
- esbuild
- '@tailwindcss/oxide'
+2 -1
View File
@@ -4,7 +4,8 @@ pkgs.mkShell {
buildInputs = with pkgs; [
# Node.js ecosystem
nodejs_24
nodePackages.pnpm
pnpm
claude-code
];
shellHook = ''
-14
View File
@@ -3,7 +3,6 @@ import { error } from '@sveltejs/kit';
import {
bookCoverUpload,
booksUpload,
bookIdsSchema,
bookQuerySchema,
deleteBookFilesSchema,
deleteBooksSchema,
@@ -149,16 +148,3 @@ export const updateBookProgress = command(
}
}
);
export const markBooksAsComplete = command(bookIdsSchema, async (data) => {
const { locals } = getRequestEvent();
const params = createQueryParams(data);
const response = await locals.api.post(`/books/completed?${params.toString()}`, {});
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
});
@@ -35,7 +35,11 @@
let formEl = $state<HTMLFormElement>();
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
uploadBooks.fields.files.set([...Array.from(files), ...uploadedFiles]);
// Rename files to use webkitRelativePath so directory structure is preserved through form submission
const renamedFiles = uploadedFiles.map(f =>
new File([f], f.webkitRelativePath || f.name, { type: f.type })
);
uploadBooks.fields.files.set([...Array.from(files), ...renamedFiles]);
if (autoUploadOnDrop && files.length > 0) {
await tick();
formEl?.requestSubmit();
@@ -5,13 +5,14 @@
import { Input } from '$lib/components/ui/input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index';
import { Button } from '$lib/components/ui/button/index.js';
import { IconPicker } from '$lib/components/ui/icon-picker/index.js';
import { toast } from 'svelte-sonner';
import { goto, invalidate, invalidateAll } from '$app/navigation';
import { page } from '$app/state';
import { getLibraryState } from '$lib/state/library.svelte';
let { open = $bindable(false) } = $props();
let selectedIcon = $state('library');
const libraryState = getLibraryState();
</script>
@@ -35,6 +36,7 @@
const libraryName = createLibrary.fields.name.value();
form.reset();
selectedIcon = 'library';
open = false;
toast.success(`Library '${libraryName}' created.`);
@@ -48,14 +50,24 @@
>
<Field.Set>
<Field.Group>
<!-- Library name -->
<Field.Field>
<Field.Label for="name">Library name</Field.Label>
<Input {...createLibrary.fields.name.as('text')} />
{#each createLibrary.fields.name.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Library name and Icon -->
<div class="flex items-end gap-3">
<div class="flex-1">
<Field.Field>
<Field.Label for="name">Library name</Field.Label>
<Input {...createLibrary.fields.name.as('text')} />
{#each createLibrary.fields.name.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
</div>
<div class="flex flex-col gap-2">
<Field.Label>Icon</Field.Label>
<IconPicker bind:value={selectedIcon} />
<input type="hidden" name="icon" value={selectedIcon} />
</div>
</div>
<!-- Description -->
<Field.Field>
@@ -4,12 +4,15 @@
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 { 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';
import LibraryCreateForm from '../forms/library-create-form.svelte';
const libraryState = getLibraryState();
const sidebar = useSidebar();
const activeIcon = $derived(LIBRARY_ICONS[libraryState.activeLibrary?.icon ?? 'library'] ?? LIBRARY_ICONS['library']);
</script>
<Sidebar.Menu>
@@ -22,11 +25,11 @@
size="lg"
class="ml-2 data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
{@const ActiveIcon = activeIcon.component}
<div
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
>
<p class="text-lg font-semibold">{libraryState.activeLibrary!.name[0]}</p>
<!-- <libraryState?.activeLibrary.logo class="size-4" /> -->
<ActiveIcon class="size-4" />
</div>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="ml-1 truncate font-semibold">
@@ -45,16 +48,16 @@
>
<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}
<DropdownMenu.Item onSelect={() => libraryState.setActive(library.id)} class="gap-2 p-2">
<div class="flex size-6 items-center justify-center rounded-md border">
<p>{library.name[0]}</p>
<!-- <library.logo class="size-3.5 shrink-0" /> -->
<LibraryIcon class="size-3.5 shrink-0" />
</div>
{library.name}
<Badge
variant="outline"
class="font-semibold ml-auto">
{library.total}
{library.total ?? 0}
</Badge>
</DropdownMenu.Item>
{/each}
@@ -0,0 +1,75 @@
<script lang="ts">
import * as Popover from '$lib/components/ui/popover/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { LIBRARY_ICONS } from './library-icons.js';
let {
value = $bindable('library')
}: {
value?: string;
} = $props();
let open = $state(false);
let search = $state('');
const filteredIcons = $derived.by(() => {
if (!search) return Object.entries(LIBRARY_ICONS);
const query = search.toLowerCase();
return Object.entries(LIBRARY_ICONS).filter(
([key, icon]) =>
key.includes(query) ||
icon.label.toLowerCase().includes(query) ||
icon.category.toLowerCase().includes(query)
);
});
function selectIcon(iconKey: string) {
value = iconKey;
open = false;
search = '';
}
const selectedIcon = $derived(LIBRARY_ICONS[value] ?? LIBRARY_ICONS['library']);
const SelectedIconComponent = $derived(selectedIcon.component);
</script>
<Popover.Root bind:open>
<Popover.Trigger asChild>
{#snippet child({ props })}
<Button variant="outline" size="icon" class="h-9 w-9" {...props}>
<SelectedIconComponent class="size-4" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-64 p-3" align="start">
<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)}
{@const IconComponent = icon.component}
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
class="flex size-8 items-center justify-center rounded-md hover:bg-accent {value === key ? 'bg-accent' : ''}"
onclick={() => selectIcon(key)}
>
<IconComponent class="size-4" />
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{icon.label}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div>
</ScrollArea>
</Popover.Content>
</Popover.Root>
@@ -0,0 +1,4 @@
import IconPicker from './icon-picker.svelte';
export { LIBRARY_ICONS, getIconComponent, type IconName } from './library-icons.js';
export { IconPicker };
@@ -0,0 +1,147 @@
import {
Library,
BookOpen,
BookMarked,
Book,
Bookmark,
FolderOpen,
Code,
Terminal,
Cpu,
Server,
Database,
Binary,
Braces,
GraduationCap,
FlaskConical,
Microscope,
Atom,
Dna,
Brain,
FileText,
ScrollText,
Calculator,
Compass,
Ruler,
PencilRuler,
Sigma,
Image,
Palette,
Sparkles,
Zap,
Feather,
PenLine,
Quote,
Wand2,
Sword,
Crown,
Flame,
Shield,
Rocket,
Satellite,
Globe,
Search,
Eye,
Fingerprint,
Heart,
HeartHandshake,
Landmark,
Scroll,
Archive,
Clock,
Star,
Rainbow,
Baby,
Headphones,
Music
} from '@lucide/svelte';
import type { Component } from 'svelte';
export type IconName = keyof typeof LIBRARY_ICONS;
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' },
'book-marked': { component: BookMarked, label: 'Book Marked', category: 'Generic' },
book: { component: Book, label: 'Book', category: 'Generic' },
bookmark: { component: Bookmark, label: 'Bookmark', category: 'Generic' },
'folder-open': { component: FolderOpen, label: 'Folder Open', category: 'Generic' },
// Technical
code: { component: Code, label: 'Code', category: 'Technical' },
terminal: { component: Terminal, label: 'Terminal', category: 'Technical' },
cpu: { component: Cpu, label: 'CPU', category: 'Technical' },
server: { component: Server, label: 'Server', category: 'Technical' },
database: { component: Database, label: 'Database', category: 'Technical' },
binary: { component: Binary, label: 'Binary', category: 'Technical' },
braces: { component: Braces, label: 'Braces', category: 'Technical' },
// Academic
'graduation-cap': { component: GraduationCap, label: 'Graduation Cap', category: 'Academic' },
'flask-conical': { component: FlaskConical, label: 'Flask', category: 'Academic' },
microscope: { component: Microscope, label: 'Microscope', category: 'Academic' },
atom: { component: Atom, label: 'Atom', category: 'Academic' },
dna: { component: Dna, label: 'DNA', category: 'Academic' },
brain: { component: Brain, label: 'Brain', category: 'Academic' },
'file-text': { component: FileText, label: 'File Text', category: 'Academic' },
'scroll-text': { component: ScrollText, label: 'Scroll Text', category: 'Academic' },
// Math/Engineering
calculator: { component: Calculator, label: 'Calculator', category: 'Math' },
compass: { component: Compass, label: 'Compass', category: 'Math' },
ruler: { component: Ruler, label: 'Ruler', category: 'Math' },
'pencil-ruler': { component: PencilRuler, label: 'Pencil Ruler', category: 'Math' },
sigma: { component: Sigma, label: 'Sigma', category: 'Math' },
// Comics/Manga
image: { component: Image, label: 'Image', category: 'Comics' },
palette: { component: Palette, label: 'Palette', category: 'Comics' },
sparkles: { component: Sparkles, label: 'Sparkles', category: 'Comics' },
zap: { component: Zap, label: 'Zap', category: 'Comics' },
// Fiction
feather: { component: Feather, label: 'Feather', category: 'Fiction' },
'pen-line': { component: PenLine, label: 'Pen', category: 'Fiction' },
quote: { component: Quote, label: 'Quote', category: 'Fiction' },
// Fantasy
wand: { component: Wand2, label: 'Wand', category: 'Fantasy' },
sword: { component: Sword, label: 'Sword', category: 'Fantasy' },
crown: { component: Crown, label: 'Crown', category: 'Fantasy' },
flame: { component: Flame, label: 'Flame', category: 'Fantasy' },
shield: { component: Shield, label: 'Shield', category: 'Fantasy' },
// Sci-Fi
rocket: { component: Rocket, label: 'Rocket', category: 'Sci-Fi' },
satellite: { component: Satellite, label: 'Satellite', category: 'Sci-Fi' },
globe: { component: Globe, label: 'Globe', category: 'Sci-Fi' },
// Mystery
search: { component: Search, label: 'Search', category: 'Mystery' },
eye: { component: Eye, label: 'Eye', category: 'Mystery' },
fingerprint: { component: Fingerprint, label: 'Fingerprint', category: 'Mystery' },
// Romance
heart: { component: Heart, label: 'Heart', category: 'Romance' },
'heart-handshake': { component: HeartHandshake, label: 'Heart Handshake', category: 'Romance' },
// History
landmark: { component: Landmark, label: 'Landmark', category: 'History' },
scroll: { component: Scroll, label: 'Scroll', category: 'History' },
archive: { component: Archive, label: 'Archive', category: 'History' },
clock: { component: Clock, label: 'Clock', category: 'History' },
// Children's
star: { component: Star, label: 'Star', category: "Children's" },
rainbow: { component: Rainbow, label: 'Rainbow', category: "Children's" },
baby: { component: Baby, label: 'Baby', category: "Children's" },
// Audio
headphones: { component: Headphones, label: 'Headphones', category: 'Audio' },
music: { component: Music, label: 'Music', category: 'Audio' }
};
export function getIconComponent(name: string): Component {
return LIBRARY_ICONS[name]?.component ?? LIBRARY_ICONS['library'].component;
}
@@ -0,0 +1,19 @@
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,
Content,
Trigger,
Close,
Portal,
//
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose,
Portal as PopoverPortal,
};
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
</script>
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
@@ -0,0 +1,31 @@
<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";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
align = "center",
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
} = $props();
</script>
<PopoverPortal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{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",
className
)}
{...restProps}
/>
</PopoverPortal>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
</script>
<PopoverPrimitive.Portal {...restProps} />
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Popover as PopoverPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
...restProps
}: PopoverPrimitive.TriggerProps = $props();
</script>
<PopoverPrimitive.Trigger
bind:ref
data-slot="popover-trigger"
class={cn("", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
</script>
<PopoverPrimitive.Root bind:open {...restProps} />
@@ -91,7 +91,7 @@
<Spinner />
</div>
{:else if bookCollection.books.length > 0}
<ScrollArea bind:viewportRef={scrollContainer} orientation="both" class=" h-[calc(100vh-12vh)] w-full pr-5 pl-5">
<ScrollArea bind:viewportRef={scrollContainer} orientation="both" class="h-[calc(100vh-11rem)] w-full px-5 pb-5">
{#if view === 'grid'}
<BookGrid books={bookCollection.books} />
{:else}
@@ -1,135 +0,0 @@
<script lang="ts">
import * as Accordion from '$lib/components/ui/accordion/index';
import * as Tooltip from '$lib/components/ui/tooltip/index';
import * as Table from '$lib/components/ui/table/index';
// import * as AlertDialog from '$lib/components/ui/alert-dialog/index';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import type { Book } from '$lib/schema/index';
import { Badge } from '$lib/components/ui/badge/index';
import { formatFileSize, getFileType } from '$lib/utils';
import { BookOpenText, Download } from '@lucide/svelte';
let { book }: { book: Book } = $props();
let files = $state(book.files);
</script>
<Accordion.Root type="single" class="mt-4 w-full rounded-lg bg-muted px-4 shadow-lg drop-shadow ">
<!-- <Separator></Separator> -->
<Accordion.Item value="item-1">
<Accordion.Trigger>
<div class="ml-2 flex gap-4">
Library Files
<Badge>{book.files.length}</Badge>
</div>
</Accordion.Trigger>
<Accordion.Content>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[300px]">Filename</Table.Head>
<Table.Head>Size</Table.Head>
<Table.Head>File type</Table.Head>
<Table.Head class="text-right">Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each book.files as file (file.id)}
<Table.Row>
<Table.Cell class="">{file.filename}</Table.Cell>
<Table.Cell>{formatFileSize(file.size)}</Table.Cell>
<Table.Cell>{getFileType(file.filename)}</Table.Cell>
<Table.Cell class="text-right">
<div class="flex justify-end gap-1">
<!-- Read button -->
{#if getFileType(file.filename) == 'EPUB' || getFileType(file.filename) == 'PDF'}
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger
class="{buttonVariants({
variant: 'default',
size: 'icon'
})} scale-90"
onclick={() => handleRead(file)}
>
<BookOpenText />
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<p>Read</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{/if}
<!-- Download button -->
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger
class="{buttonVariants({ variant: 'default', size: 'icon' })} scale-90"
onclick={() => handleDownload(file)}
>
<Download />
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<p>Download</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
<!-- Delete button -->
<AlertDialog.Root bind:open={fileDeleteDialogOpen}>
<AlertDialog.Trigger>
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger
class="{buttonVariants({
variant: 'destructive',
size: 'icon'
})} scale-90"
onclick={() => {
fileToDelete = file.id;
}}
>
<Trash2 />
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<p>Delete</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Are you absolutely sure?</AlertDialog.Title>
<AlertDialog.Description>
This action cannot be undone. This will permanently delete the record from
the database and the file from the filesystem.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action
onclick={() => {
handleDeleteFile(fileToDelete);
fileDeleteDialogOpen = false;
fileToDelete = undefined;
}}
class="text-destructive-foreground bg-destructive hover:bg-destructive/90"
>Delete</AlertDialog.Action
>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</div>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
+5 -8
View File
@@ -77,27 +77,24 @@ export const deleteBooksSchema = z.object({
library_id: stringCoerce
});
export const bookIdsSchema = z.object({
book_ids: stringArrayCoerce // TODO Could also set minimum length of 1
});
export const deleteBookFilesSchema = z.object({
book_id: stringCoerce,
file_ids: stringArrayCoerce,
delete_files: z.boolean()
});
// Mirrors BookProgressCreate in backend/src/chitai/schemas/book.py
export const updateBookProgressSchema = z.object({
book_ids: stringArrayCoerce,
progress: number().min(0).max(1),
percentage: number().min(0).max(1),
completed: z.boolean().optional().default(false),
epub_loc: z.string().optional(),
pdf_loc: z.string().optional()
epub_cfi: z.string().optional(),
epub_xpointer: z.string().optional(),
pdf_page: z.number().int().optional()
});
export type BookQuery = z.infer<typeof bookQuerySchema>;
export type BookEditMetadata = z.infer<typeof editBookMetadataSchema>;
export type DeleteBook = z.infer<typeof deleteBooksSchema>;
export type BookIds = z.infer<typeof bookIdsSchema>;
export type DeleteBookFiles = z.infer<typeof deleteBookFilesSchema>;
export type UpdateBookProgress = z.infer<typeof updateBookProgressSchema>;
+2 -1
View File
@@ -13,7 +13,8 @@ export const libraryCreateSchema = z.object({
name: z.string().min(1, 'Library must have a name'),
description: z.string().optional(),
root_path: z.string().min(1, 'Must specify a root path'),
path_template: z.string().optional().default('/{author}/{title}')
path_template: z.string().optional().default('/{author}/{title}'),
icon: z.string().default('library')
});
export type LibraryQuerySchema = typeof libraryQuerySchema;
+87 -58
View File
@@ -666,6 +666,17 @@ export interface components {
device_type?: string | null;
device_id?: string | null;
};
/** BookProgressRead */
BookProgressRead: {
percentage: number;
epub_cfi?: string | null;
epub_xpointer?: string | null;
pdf_page?: number | null;
/** @default false */
completed: boolean | null;
device_type?: string | null;
device_id?: string | null;
};
/** BookRead */
BookRead: {
id: number;
@@ -688,7 +699,7 @@ export interface components {
series?: components["schemas"]["BookSeriesRead"] | null;
series_position?: string | null;
files: components["schemas"]["FileMetadataRead"][];
progress?: unknown | null;
progress?: components["schemas"]["BookProgressRead"] | null;
};
/** BookSeriesRead */
BookSeriesRead: {
@@ -742,6 +753,8 @@ export interface components {
/** @default {author}/{title} */
path_template: string | null;
description?: string | null;
/** @default library */
icon: string;
/** @default false */
read_only: boolean;
readonly slug: string;
@@ -753,6 +766,7 @@ export interface components {
root_path: string;
path_template: string;
description?: string | null;
icon: string;
read_only: boolean;
total?: number | null;
};
@@ -763,55 +777,6 @@ export interface components {
refresh_token?: string | null;
expires_in?: number | null;
};
/** OffsetPagination[AuthorRead] */
"OffsetPagination_chitai.schemas.author.AuthorRead_": {
items: components["schemas"]["AuthorRead"][];
limit: number;
offset: number;
total: number;
};
/** OffsetPagination[BookRead] */
"OffsetPagination_chitai.schemas.book.BookRead_": {
items: components["schemas"]["BookRead"][];
limit: number;
offset: number;
total: number;
};
/** OffsetPagination[KosyncDeviceRead] */
"OffsetPagination_chitai.schemas.kosync.KosyncDeviceRead_": {
items: components["schemas"]["KosyncDeviceRead"][];
limit: number;
offset: number;
total: number;
};
/** OffsetPagination[LibraryRead] */
"OffsetPagination_chitai.schemas.library.LibraryRead_": {
items: components["schemas"]["LibraryRead"][];
limit: number;
offset: number;
total: number;
};
/** OffsetPagination[PublisherRead] */
"OffsetPagination_chitai.schemas.publisher.PublisherRead_": {
items: components["schemas"]["PublisherRead"][];
limit: number;
offset: number;
total: number;
};
/** OffsetPagination[ShelfRead] */
"OffsetPagination_chitai.schemas.shelf.ShelfRead_": {
items: components["schemas"]["ShelfRead"][];
limit: number;
offset: number;
total: number;
};
/** OffsetPagination[TagRead] */
"OffsetPagination_chitai.schemas.tag.TagRead_": {
items: components["schemas"]["TagRead"][];
limit: number;
offset: number;
total: number;
};
/** PublisherRead */
PublisherRead: {
id: number;
@@ -972,7 +937,15 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OffsetPagination_chitai.schemas.book.BookRead_"];
"application/json": {
items?: components["schemas"]["BookRead"][];
/** @description Maximal number of items to send. */
limit?: number;
/** @description Offset from the beginning of the query. */
offset?: number;
/** @description Total number of items. */
total?: number;
};
};
};
/** @description Bad request syntax or unsupported method */
@@ -1091,7 +1064,15 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OffsetPagination_chitai.schemas.book.BookRead_"];
"application/json": {
items?: components["schemas"]["BookRead"][];
/** @description Maximal number of items to send. */
limit?: number;
/** @description Offset from the beginning of the query. */
offset?: number;
/** @description Total number of items. */
total?: number;
};
};
};
/** @description Bad request syntax or unsupported method */
@@ -1416,7 +1397,15 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OffsetPagination_chitai.schemas.library.LibraryRead_"];
"application/json": {
items?: components["schemas"]["LibraryRead"][];
/** @description Maximal number of items to send. */
limit?: number;
/** @description Offset from the beginning of the query. */
offset?: number;
/** @description Total number of items. */
total?: number;
};
};
};
/** @description Bad request syntax or unsupported method */
@@ -1673,7 +1662,15 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OffsetPagination_chitai.schemas.shelf.ShelfRead_"];
"application/json": {
items?: components["schemas"]["ShelfRead"][];
/** @description Maximal number of items to send. */
limit?: number;
/** @description Offset from the beginning of the query. */
offset?: number;
/** @description Total number of items. */
total?: number;
};
};
};
/** @description Bad request syntax or unsupported method */
@@ -1791,7 +1788,15 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OffsetPagination_chitai.schemas.author.AuthorRead_"];
"application/json": {
items?: components["schemas"]["AuthorRead"][];
/** @description Maximal number of items to send. */
limit?: number;
/** @description Offset from the beginning of the query. */
offset?: number;
/** @description Total number of items. */
total?: number;
};
};
};
/** @description Bad request syntax or unsupported method */
@@ -1835,7 +1840,15 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OffsetPagination_chitai.schemas.publisher.PublisherRead_"];
"application/json": {
items?: components["schemas"]["PublisherRead"][];
/** @description Maximal number of items to send. */
limit?: number;
/** @description Offset from the beginning of the query. */
offset?: number;
/** @description Total number of items. */
total?: number;
};
};
};
/** @description Bad request syntax or unsupported method */
@@ -1879,7 +1892,15 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OffsetPagination_chitai.schemas.tag.TagRead_"];
"application/json": {
items?: components["schemas"]["TagRead"][];
/** @description Maximal number of items to send. */
limit?: number;
/** @description Offset from the beginning of the query. */
offset?: number;
/** @description Total number of items. */
total?: number;
};
};
};
/** @description Bad request syntax or unsupported method */
@@ -2181,7 +2202,15 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OffsetPagination_chitai.schemas.kosync.KosyncDeviceRead_"];
"application/json": {
items?: components["schemas"]["KosyncDeviceRead"][];
/** @description Maximal number of items to send. */
limit?: number;
/** @description Offset from the beginning of the query. */
offset?: number;
/** @description Total number of items. */
total?: number;
};
};
};
};
@@ -101,12 +101,12 @@ export class BookOperationsState {
async markBooksAsComplete(bookIds: string[] | number[]) {
try {
await this.updateBookProgress(bookIds, {
progress: 1,
percentage: 1,
completed: true
});
if (bookIds.length > 1) toast.success('Book marked as complete!');
else toast.success(`${bookIds.length} books marked as complete!`);
if (bookIds.length > 1) toast.success(`${bookIds.length} books marked as complete!`);
else toast.success('Book marked as complete!');
// Get fresh data
await this.reload();
@@ -119,12 +119,12 @@ export class BookOperationsState {
async markBooksAsIncomplete(bookIds: number[]) {
try {
await this.updateBookProgress(bookIds, {
progress: 0,
percentage: 0,
completed: false
});
if (bookIds.length > 1) toast.success('Book progress reset!');
else toast.success(`Reset progress for ${bookIds.length} books!`);
if (bookIds.length > 1) toast.success(`Reset progress for ${bookIds.length} books!`);
else toast.success('Book progress reset!');
// get fresh data
await this.reload();