Compare commits
17
Commits
9711c68fbb
...
510306f24d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
510306f24d | ||
|
|
bd8d68b9ba | ||
|
|
540522e828 | ||
|
|
a1f39a8dc8 | ||
|
|
dfeb4c3267 | ||
|
|
e4ecfad5dd | ||
|
|
3a3957d432 | ||
|
|
23a12f7970 | ||
|
|
c3e337de32 | ||
|
|
3ae3dcb0d0 | ||
|
|
3091cc879d | ||
|
|
1415cb6244 | ||
|
|
cc39e79cd7 | ||
|
|
c5b703b75a | ||
|
|
9fe69641c5 | ||
|
|
0963ee85c2 | ||
|
|
448e0e0090 |
@@ -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.
|
||||
@@ -12,4 +12,5 @@ wheels/
|
||||
# Project specific directories/files
|
||||
covers/
|
||||
books/
|
||||
libraries/
|
||||
.postgres/
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
+17
-7
@@ -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():
|
||||
+85
-9
@@ -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:
|
||||
@@ -10,6 +10,7 @@ pkgs.mkShell {
|
||||
# postgres database
|
||||
postgresql
|
||||
|
||||
claude-code
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -1,3 +1,7 @@
|
||||
allowBuilds:
|
||||
core-js: true
|
||||
es5-ext: true
|
||||
esbuild: true
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- '@tailwindcss/oxide'
|
||||
|
||||
+2
-1
@@ -4,7 +4,8 @@ pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
# Node.js ecosystem
|
||||
nodejs_24
|
||||
nodePackages.pnpm
|
||||
pnpm
|
||||
claude-code
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
|
||||
@@ -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>
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user