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
|
# Project specific directories/files
|
||||||
covers/
|
covers/
|
||||||
books/
|
books/
|
||||||
|
libraries/
|
||||||
.postgres/
|
.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:
|
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
|
import sqlalchemy as sa
|
||||||
from alembic import op
|
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
|
from sqlalchemy import Text # noqa: F401
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -26,17 +31,22 @@ sa.EncryptedString = EncryptedString
|
|||||||
sa.EncryptedText = EncryptedText
|
sa.EncryptedText = EncryptedText
|
||||||
sa.StoredObject = StoredObject
|
sa.StoredObject = StoredObject
|
||||||
sa.PasswordHash = PasswordHash
|
sa.PasswordHash = PasswordHash
|
||||||
|
sa.Argon2Hasher = Argon2Hasher
|
||||||
|
sa.PasslibHasher = PasslibHasher
|
||||||
|
sa.PwdlibHasher = PwdlibHasher
|
||||||
|
sa.FernetBackend = FernetBackend
|
||||||
|
sa.PGCryptoBackend = PGCryptoBackend
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = '26022ec86f32'
|
revision = '65aa95e8f8cf'
|
||||||
down_revision = None
|
down_revision = None
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.execute(sa.text('create EXTENSION if not EXISTS "pgcrypto"'))
|
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 "pg_trgm"'))
|
||||||
with warnings.catch_warnings():
|
with warnings.catch_warnings():
|
||||||
warnings.filterwarnings("ignore", category=UserWarning)
|
warnings.filterwarnings("ignore", category=UserWarning)
|
||||||
with op.get_context().autocommit_block():
|
with op.get_context().autocommit_block():
|
||||||
+85
-9
@@ -1,8 +1,8 @@
|
|||||||
"""create initial tables
|
"""create initial tables
|
||||||
|
|
||||||
Revision ID: 43bdf42a4f6c
|
Revision ID: 6d72d1bbc0ee
|
||||||
Revises:
|
Revises: 65aa95e8f8cf
|
||||||
Create Date: 2026-03-08 16:00:54.727868
|
Create Date: 2026-03-14 14:36:43.529584
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -38,8 +38,8 @@ sa.FernetBackend = FernetBackend
|
|||||||
sa.PGCryptoBackend = PGCryptoBackend
|
sa.PGCryptoBackend = PGCryptoBackend
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = '43bdf42a4f6c'
|
revision = '6d72d1bbc0ee'
|
||||||
down_revision = None
|
down_revision = '65aa95e8f8cf'
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
@@ -61,6 +61,61 @@ def downgrade() -> None:
|
|||||||
def schema_upgrades() -> None:
|
def schema_upgrades() -> None:
|
||||||
"""schema upgrade migrations go here."""
|
"""schema upgrade migrations go here."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### 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',
|
op.create_table('users',
|
||||||
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
|
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
|
||||||
sa.Column('email', sa.String(), 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.Column('position', sa.Integer(), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['author_id'], ['authors.id'], name=op.f('fk_book_author_links_author_id_authors')),
|
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.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',
|
op.create_table('book_list_links',
|
||||||
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
|
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.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(['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.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',
|
op.create_table('book_progress',
|
||||||
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
|
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.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(['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.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',
|
op.create_table('file_metadata',
|
||||||
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
|
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('book_id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
|
||||||
sa.Column('value', sa.String(), 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.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',
|
op.create_table('kosync_progress',
|
||||||
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
|
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('books')
|
||||||
op.drop_table('book_lists')
|
op.drop_table('book_lists')
|
||||||
op.drop_table('users')
|
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 ###
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
def data_upgrades() -> None:
|
def data_upgrades() -> None:
|
||||||
@@ -10,6 +10,7 @@ pkgs.mkShell {
|
|||||||
# postgres database
|
# postgres database
|
||||||
postgresql
|
postgresql
|
||||||
|
|
||||||
|
claude-code
|
||||||
];
|
];
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ from advanced_alchemy.extensions.litestar import (
|
|||||||
AsyncSessionConfig,
|
AsyncSessionConfig,
|
||||||
SQLAlchemyPlugin,
|
SQLAlchemyPlugin,
|
||||||
)
|
)
|
||||||
|
from advanced_alchemy.base import BigIntAuditBase
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
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)
|
DATABASE_URL = str(settings.postgres_uri)
|
||||||
|
|
||||||
@@ -16,8 +17,8 @@ config = SQLAlchemyAsyncConfig(
|
|||||||
engine_instance=create_async_engine(DATABASE_URL, echo=settings.postgres_echo),
|
engine_instance=create_async_engine(DATABASE_URL, echo=settings.postgres_echo),
|
||||||
session_config=session_config,
|
session_config=session_config,
|
||||||
before_send_handler="autocommit",
|
before_send_handler="autocommit",
|
||||||
create_all=True,
|
create_all=False,
|
||||||
|
metadata=BigIntAuditBase.registry.metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
alchemy = SQLAlchemyPlugin(config=config)
|
alchemy = SQLAlchemyPlugin(config=config)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
from collections.abc import Hashable
|
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
|
||||||
from sqlalchemy.orm import mapped_column
|
from sqlalchemy.orm import mapped_column
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
@@ -35,11 +35,10 @@ class Author(BigIntAuditBase, UniqueMixin):
|
|||||||
|
|
||||||
class BookAuthorLink(BigIntBase):
|
class BookAuthorLink(BigIntBase):
|
||||||
__tablename__ = "book_author_links"
|
__tablename__ = "book_author_links"
|
||||||
|
__table_args__ = (UniqueConstraint("book_id", "author_id"),)
|
||||||
|
|
||||||
book_id: Mapped[int] = mapped_column(
|
book_id: Mapped[int] = mapped_column(ForeignKey("books.id", ondelete="cascade"))
|
||||||
ForeignKey("books.id", ondelete="cascade"), primary_key=True
|
author_id: Mapped[int] = mapped_column(ForeignKey("authors.id"))
|
||||||
)
|
|
||||||
author_id: Mapped[int] = mapped_column(ForeignKey("authors.id"), primary_key=True)
|
|
||||||
|
|
||||||
position: Mapped[int]
|
position: Mapped[int]
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import TYPE_CHECKING, Any, Optional
|
from typing import TYPE_CHECKING, Any, Optional
|
||||||
|
|
||||||
from sqlalchemy import Index
|
from sqlalchemy import Index, ForeignKey, UniqueConstraint
|
||||||
from sqlalchemy import ForeignKey
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.orm import mapped_column
|
from sqlalchemy.orm import mapped_column
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
@@ -127,11 +126,10 @@ class Book(BigIntAuditBase):
|
|||||||
|
|
||||||
class Identifier(BigIntBase):
|
class Identifier(BigIntBase):
|
||||||
__tablename__ = "identifiers"
|
__tablename__ = "identifiers"
|
||||||
|
__table_args__ = (UniqueConstraint("name", "book_id"),)
|
||||||
|
|
||||||
name: Mapped[str] = mapped_column(primary_key=True)
|
name: Mapped[str]
|
||||||
book_id: Mapped[int] = mapped_column(
|
book_id: Mapped[int] = mapped_column(ForeignKey("books.id", ondelete="cascade"))
|
||||||
ForeignKey("books.id", ondelete="cascade"), primary_key=True
|
|
||||||
)
|
|
||||||
value: Mapped[str]
|
value: Mapped[str]
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from sqlalchemy import ForeignKey
|
from sqlalchemy import ForeignKey, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy
|
from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy
|
||||||
from sqlalchemy.ext.orderinglist import ordering_list
|
from sqlalchemy.ext.orderinglist import ordering_list
|
||||||
@@ -38,10 +38,10 @@ class BookList(BigIntAuditBase):
|
|||||||
|
|
||||||
class BookListLink(BigIntBase):
|
class BookListLink(BigIntBase):
|
||||||
__tablename__ = "book_list_links"
|
__tablename__ = "book_list_links"
|
||||||
book_id: Mapped[int] = mapped_column(
|
__table_args__ = (UniqueConstraint("book_id", "list_id"),)
|
||||||
ForeignKey("books.id", ondelete="cascade"), primary_key=True
|
|
||||||
)
|
book_id: Mapped[int] = mapped_column(ForeignKey("books.id", ondelete="cascade"))
|
||||||
list_id: Mapped[int] = mapped_column(ForeignKey("book_lists.id"), primary_key=True)
|
list_id: Mapped[int] = mapped_column(ForeignKey("book_lists.id"))
|
||||||
position: Mapped[int]
|
position: Mapped[int]
|
||||||
|
|
||||||
book: Mapped[Book] = relationship(back_populates="list_links")
|
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})
|
# Which structure to save the files in the filesystem (i.e {author_name}/{title}.{ext})
|
||||||
path_template: Mapped[str]
|
path_template: Mapped[str]
|
||||||
description: Mapped[Optional[str]]
|
description: Mapped[Optional[str]]
|
||||||
|
icon: Mapped[str] = mapped_column(default="library")
|
||||||
read_only: Mapped[bool] = mapped_column(nullable=False, default=False)
|
read_only: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||||
|
|
||||||
books: Mapped[list["Book"]] = relationship(back_populates="library")
|
books: Mapped[list["Book"]] = relationship(back_populates="library")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from collections.abc import Hashable
|
from collections.abc import Hashable
|
||||||
from typing import TYPE_CHECKING
|
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
|
||||||
from sqlalchemy.orm import mapped_column
|
from sqlalchemy.orm import mapped_column
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
@@ -35,15 +35,12 @@ class Tag(BigIntBase, UniqueMixin):
|
|||||||
|
|
||||||
class BookTagLink(BigIntBase):
|
class BookTagLink(BigIntBase):
|
||||||
__tablename__ = "book_tag_link"
|
__tablename__ = "book_tag_link"
|
||||||
|
__table_args__ = (UniqueConstraint("book_id", "tag_id"),)
|
||||||
|
|
||||||
book_id: Mapped[int] = mapped_column(
|
book_id: Mapped[int] = mapped_column(ForeignKey("books.id", ondelete="cascade"))
|
||||||
ForeignKey("books.id", ondelete="cascade"), primary_key=True
|
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"))
|
||||||
)
|
|
||||||
|
|
||||||
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
|
|
||||||
|
|
||||||
position: Mapped[int]
|
position: Mapped[int]
|
||||||
|
|
||||||
book: Mapped["Book"] = relationship(back_populates="tag_links")
|
book: Mapped["Book"] = relationship(back_populates="tag_links")
|
||||||
|
|
||||||
tag: Mapped[Tag] = relationship()
|
tag: Mapped[Tag] = relationship()
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ class FileMetadataRead(BaseModel):
|
|||||||
return Path(self.path).name
|
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):
|
class BookRead(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
library_id: int
|
library_id: int
|
||||||
@@ -170,11 +180,3 @@ class BookProgressCreate(BaseModel):
|
|||||||
device_id: str | None = None
|
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
|
root_path: str
|
||||||
path_template: str | None = "{author}/{title}"
|
path_template: str | None = "{author}/{title}"
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
icon: str = "library"
|
||||||
read_only: bool = False
|
read_only: bool = False
|
||||||
|
|
||||||
@computed_field
|
@computed_field
|
||||||
@@ -21,6 +22,7 @@ class LibraryRead(BaseModel):
|
|||||||
root_path: str
|
root_path: str
|
||||||
path_template: str
|
path_template: str
|
||||||
description: str | None
|
description: str | None
|
||||||
|
icon: str
|
||||||
read_only: bool
|
read_only: bool
|
||||||
total: int | None = None
|
total: int | None = None
|
||||||
|
|
||||||
@@ -30,4 +32,5 @@ class LibraryUpdate(BaseModel):
|
|||||||
root_path: str | None
|
root_path: str | None
|
||||||
path_template: str | None
|
path_template: str | None
|
||||||
description: str | None
|
description: str | None
|
||||||
|
icon: str | None
|
||||||
read_only: bool | None
|
read_only: bool | None
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ from __future__ import annotations
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
|
||||||
from io import BytesIO
|
from collections.abc import Callable
|
||||||
|
from io import BytesIO, RawIOBase
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import uuid
|
import uuid
|
||||||
import zipfile
|
import zipfile
|
||||||
@@ -33,6 +34,8 @@ from chitai.config import settings
|
|||||||
from chitai.database.models import (
|
from chitai.database.models import (
|
||||||
Book,
|
Book,
|
||||||
Author,
|
Author,
|
||||||
|
BookAuthorLink,
|
||||||
|
BookTagLink,
|
||||||
Tag,
|
Tag,
|
||||||
Publisher,
|
Publisher,
|
||||||
BookSeries,
|
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]):
|
class BookService(SQLAlchemyAsyncRepositoryService[Book]):
|
||||||
"""Book service for managing book operations."""
|
"""Book service for managing book operations."""
|
||||||
|
|
||||||
@@ -112,15 +152,24 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
|
|||||||
if not data.files:
|
if not data.files:
|
||||||
raise ValueError("Must upload at least one file")
|
raise ValueError("Must upload at least one file")
|
||||||
|
|
||||||
# Group book files if they are within the same nested directory
|
# Group the files that make up a single book.
|
||||||
books = defaultdict(list)
|
#
|
||||||
|
# `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:
|
for file in data.files:
|
||||||
filepath = Path(file.filename)
|
filepath = Path(file.filename)
|
||||||
# Books within the root directory should be treated as separate books
|
|
||||||
if len(filepath.parent.parts) > 1:
|
if len(filepath.parent.parts) > 1:
|
||||||
books[filepath.parent].append(file)
|
books[(filepath.parent, "")].append(file)
|
||||||
else:
|
else:
|
||||||
books[filepath].append(file)
|
books[(filepath.parent, filepath.stem)].append(file)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
await self.create_book(
|
await self.create_book(
|
||||||
@@ -281,7 +330,10 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
|
|||||||
"""
|
"""
|
||||||
Get all selected book files as a compressed zip file.
|
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:
|
Args:
|
||||||
book_ids: List of book IDs to include in the zip.
|
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)
|
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]
|
stream = _ZipStream()
|
||||||
|
|
||||||
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)
|
|
||||||
chunk_size = 32768 # 32 KiB
|
chunk_size = 32768 # 32 KiB
|
||||||
while True:
|
|
||||||
chunk = buffer.read(chunk_size)
|
with zipfile.ZipFile(stream, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||||
if not chunk:
|
for book in books:
|
||||||
break
|
# 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
|
yield chunk
|
||||||
|
|
||||||
async def update_book(
|
async def update_book(
|
||||||
@@ -475,10 +546,16 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
|
|||||||
model_data = await super().to_model(data)
|
model_data = await super().to_model(data)
|
||||||
|
|
||||||
if "authors" in data:
|
if "authors" in data:
|
||||||
model_data.authors = [
|
authors = [
|
||||||
await Author.as_unique_async(self.repository.session, name=author)
|
await Author.as_unique_async(self.repository.session, name=author)
|
||||||
for author in data["authors"]
|
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 "series" in data:
|
||||||
if data["series"]:
|
if data["series"]:
|
||||||
@@ -497,19 +574,96 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
|
|||||||
model_data.publisher = None
|
model_data.publisher = None
|
||||||
|
|
||||||
if "tags" in data:
|
if "tags" in data:
|
||||||
model_data.tags = [
|
tags = [
|
||||||
await Tag.as_unique_async(self.repository.session, name=tag)
|
await Tag.as_unique_async(self.repository.session, name=tag)
|
||||||
for tag in data["tags"]
|
for tag in data["tags"]
|
||||||
]
|
]
|
||||||
|
self._sync_link_collection(
|
||||||
|
model_data.tag_links,
|
||||||
|
tags,
|
||||||
|
"tag",
|
||||||
|
lambda tag: BookTagLink(tag=tag),
|
||||||
|
)
|
||||||
|
|
||||||
if "identifiers" in data:
|
if "identifiers" in data:
|
||||||
model_data.identifiers = data["identifiers"]
|
self._sync_identifiers(model_data, data["identifiers"])
|
||||||
|
|
||||||
if "files" in data:
|
if "files" in data:
|
||||||
model_data.files = data["files"]
|
model_data.files = data["files"]
|
||||||
|
|
||||||
return model_data
|
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]:
|
async def _save_book_files(self, library: Library, data: dict) -> list[FileMetadata]:
|
||||||
"""
|
"""
|
||||||
Save uploaded book files to the filesystem.
|
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.exceptions import NotFoundError
|
||||||
from advanced_alchemy.filters import CollectionFilter, StatementFilter
|
from advanced_alchemy.filters import CollectionFilter, StatementFilter
|
||||||
from advanced_alchemy.service import FilterTypeT
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from litestar import Request
|
from litestar import Request
|
||||||
from litestar.params import Dependency, Parameter
|
from litestar.params import Dependency, Parameter
|
||||||
@@ -51,8 +51,19 @@ from chitai.services.filters.book import (
|
|||||||
|
|
||||||
|
|
||||||
async def provide_book_service(
|
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]:
|
) -> 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 = [
|
load = [
|
||||||
selectinload(m.Book.author_links).selectinload(m.BookAuthorLink.author),
|
selectinload(m.Book.author_links).selectinload(m.BookAuthorLink.author),
|
||||||
selectinload(m.Book.tag_links).selectinload(m.BookTagLink.tag),
|
selectinload(m.Book.tag_links).selectinload(m.BookTagLink.tag),
|
||||||
@@ -60,30 +71,25 @@ async def provide_book_service(
|
|||||||
m.Book.files,
|
m.Book.files,
|
||||||
m.Book.identifiers,
|
m.Book.identifiers,
|
||||||
m.Book.series,
|
m.Book.series,
|
||||||
]
|
# Reading progress, restricted to the caller.
|
||||||
|
#
|
||||||
# Load in specific user-book data
|
# The restriction lives on the relationship via .and_() rather than in a
|
||||||
if current_user:
|
# separate with_loader_criteria(). advanced_alchemy's
|
||||||
# Load progress data
|
# get_abstract_loader_options() keeps only _AbstractLoad,
|
||||||
load.extend(
|
# InstrumentedAttribute, RelationshipProperty and "*" entries and drops
|
||||||
[
|
# everything else — and with_loader_criteria() is none of those, so the
|
||||||
selectinload(m.Book.progress_records),
|
# previous criteria were discarded before reaching a query. A
|
||||||
with_loader_criteria(
|
# selectinload() carrying its own .and_() survives that filter.
|
||||||
m.BookProgress, m.BookProgress.user_id == current_user.id
|
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 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(
|
provider_func = create_service_provider(
|
||||||
BookService,
|
BookService,
|
||||||
|
|||||||
@@ -67,8 +67,11 @@ class Extractor:
|
|||||||
for file in files:
|
for file in files:
|
||||||
metadata = FilenameExtractor.extract_metadata(file) | metadata
|
metadata = FilenameExtractor.extract_metadata(file) | metadata
|
||||||
|
|
||||||
# Get metadata from filepath
|
# Get metadata from filepath. Kept on the left so that anything the file
|
||||||
metadata = metadata | FilepathExtractor.extract_metadata(files[0], root_path)
|
# 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
|
# format the title
|
||||||
if metadata.get('title', None):
|
if metadata.get('title', None):
|
||||||
@@ -155,7 +158,11 @@ class PdfExtractor(FileExtractor):
|
|||||||
if isinstance(data, UploadFile):
|
if isinstance(data, UploadFile):
|
||||||
data = data.file
|
data = data.file
|
||||||
|
|
||||||
|
try:
|
||||||
doc = pypdfium2.PdfDocument(data)
|
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)
|
basic_metadata = doc.get_metadata_dict(skip_empty=False)
|
||||||
metadata["title"] = basic_metadata["Title"]
|
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
|
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:
|
# async def test_delete_book_metadata(authenticated_client: AsyncClient) -> None:
|
||||||
# raise NotImplementedError()
|
# raise NotImplementedError()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
"""Tests for BookService"""
|
"""Tests for BookService"""
|
||||||
|
|
||||||
|
import zipfile
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import aiofiles.os as aios
|
import aiofiles.os as aios
|
||||||
|
|
||||||
@@ -67,3 +71,134 @@ class TestBookServiceCRUD:
|
|||||||
assert updated_book.publisher.name == "Tolkien Estate"
|
assert updated_book.publisher.name == "Tolkien Estate"
|
||||||
|
|
||||||
assert len(updated_book.tags) == 2
|
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:
|
onlyBuiltDependencies:
|
||||||
- esbuild
|
- esbuild
|
||||||
- '@tailwindcss/oxide'
|
- '@tailwindcss/oxide'
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,8 @@ pkgs.mkShell {
|
|||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
# Node.js ecosystem
|
# Node.js ecosystem
|
||||||
nodejs_24
|
nodejs_24
|
||||||
nodePackages.pnpm
|
pnpm
|
||||||
|
claude-code
|
||||||
];
|
];
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { error } from '@sveltejs/kit';
|
|||||||
import {
|
import {
|
||||||
bookCoverUpload,
|
bookCoverUpload,
|
||||||
booksUpload,
|
booksUpload,
|
||||||
bookIdsSchema,
|
|
||||||
bookQuerySchema,
|
bookQuerySchema,
|
||||||
deleteBookFilesSchema,
|
deleteBookFilesSchema,
|
||||||
deleteBooksSchema,
|
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>();
|
let formEl = $state<HTMLFormElement>();
|
||||||
|
|
||||||
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
|
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) {
|
if (autoUploadOnDrop && files.length > 0) {
|
||||||
await tick();
|
await tick();
|
||||||
formEl?.requestSubmit();
|
formEl?.requestSubmit();
|
||||||
|
|||||||
@@ -5,13 +5,14 @@
|
|||||||
import { Input } from '$lib/components/ui/input/index.js';
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
import { Textarea } from '$lib/components/ui/textarea/index';
|
import { Textarea } from '$lib/components/ui/textarea/index';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
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 { toast } from 'svelte-sonner';
|
||||||
import { goto, invalidate, invalidateAll } from '$app/navigation';
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import { getLibraryState } from '$lib/state/library.svelte';
|
import { getLibraryState } from '$lib/state/library.svelte';
|
||||||
|
|
||||||
let { open = $bindable(false) } = $props();
|
let { open = $bindable(false) } = $props();
|
||||||
|
|
||||||
|
let selectedIcon = $state('library');
|
||||||
|
|
||||||
const libraryState = getLibraryState();
|
const libraryState = getLibraryState();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
const libraryName = createLibrary.fields.name.value();
|
const libraryName = createLibrary.fields.name.value();
|
||||||
|
|
||||||
form.reset();
|
form.reset();
|
||||||
|
selectedIcon = 'library';
|
||||||
open = false;
|
open = false;
|
||||||
toast.success(`Library '${libraryName}' created.`);
|
toast.success(`Library '${libraryName}' created.`);
|
||||||
|
|
||||||
@@ -48,7 +50,9 @@
|
|||||||
>
|
>
|
||||||
<Field.Set>
|
<Field.Set>
|
||||||
<Field.Group>
|
<Field.Group>
|
||||||
<!-- Library name -->
|
<!-- Library name and Icon -->
|
||||||
|
<div class="flex items-end gap-3">
|
||||||
|
<div class="flex-1">
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label for="name">Library name</Field.Label>
|
<Field.Label for="name">Library name</Field.Label>
|
||||||
<Input {...createLibrary.fields.name.as('text')} />
|
<Input {...createLibrary.fields.name.as('text')} />
|
||||||
@@ -56,6 +60,14 @@
|
|||||||
<Field.Error>{issue.message}</Field.Error>
|
<Field.Error>{issue.message}</Field.Error>
|
||||||
{/each}
|
{/each}
|
||||||
</Field.Field>
|
</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 -->
|
<!-- Description -->
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
|
|||||||
@@ -4,12 +4,15 @@
|
|||||||
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
|
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
|
||||||
import { Badge } from "$lib/components/ui/badge/index.js";
|
import { Badge } from "$lib/components/ui/badge/index.js";
|
||||||
import { getLibraryState, LibraryState } from '$lib/state/library.svelte';
|
import { getLibraryState, 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 ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
|
||||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||||
import LibraryCreateForm from '../forms/library-create-form.svelte';
|
import LibraryCreateForm from '../forms/library-create-form.svelte';
|
||||||
|
|
||||||
const libraryState = getLibraryState();
|
const libraryState = getLibraryState();
|
||||||
const sidebar = useSidebar();
|
const sidebar = useSidebar();
|
||||||
|
|
||||||
|
const activeIcon = $derived(LIBRARY_ICONS[libraryState.activeLibrary?.icon ?? 'library'] ?? LIBRARY_ICONS['library']);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Sidebar.Menu>
|
<Sidebar.Menu>
|
||||||
@@ -22,11 +25,11 @@
|
|||||||
size="lg"
|
size="lg"
|
||||||
class="ml-2 data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
class="ml-2 data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
>
|
>
|
||||||
|
{@const ActiveIcon = activeIcon.component}
|
||||||
<div
|
<div
|
||||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
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>
|
<ActiveIcon class="size-4" />
|
||||||
<!-- <libraryState?.activeLibrary.logo class="size-4" /> -->
|
|
||||||
</div>
|
</div>
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span class="ml-1 truncate font-semibold">
|
<span class="ml-1 truncate font-semibold">
|
||||||
@@ -45,16 +48,16 @@
|
|||||||
>
|
>
|
||||||
<DropdownMenu.Label class="text-xs text-muted-foreground">Libraries</DropdownMenu.Label>
|
<DropdownMenu.Label class="text-xs text-muted-foreground">Libraries</DropdownMenu.Label>
|
||||||
{#each libraryState.libraries as library (library.name)}
|
{#each libraryState.libraries as library (library.name)}
|
||||||
|
{@const LibraryIcon = (LIBRARY_ICONS[library.icon ?? 'library'] ?? LIBRARY_ICONS['library']).component}
|
||||||
<DropdownMenu.Item onSelect={() => libraryState.setActive(library.id)} class="gap-2 p-2">
|
<DropdownMenu.Item onSelect={() => libraryState.setActive(library.id)} class="gap-2 p-2">
|
||||||
<div class="flex size-6 items-center justify-center rounded-md border">
|
<div class="flex size-6 items-center justify-center rounded-md border">
|
||||||
<p>{library.name[0]}</p>
|
<LibraryIcon class="size-3.5 shrink-0" />
|
||||||
<!-- <library.logo class="size-3.5 shrink-0" /> -->
|
|
||||||
</div>
|
</div>
|
||||||
{library.name}
|
{library.name}
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="font-semibold ml-auto">
|
class="font-semibold ml-auto">
|
||||||
{library.total}
|
{library.total ?? 0}
|
||||||
</Badge>
|
</Badge>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
{/each}
|
{/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 />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
{:else if bookCollection.books.length > 0}
|
{: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'}
|
{#if view === 'grid'}
|
||||||
<BookGrid books={bookCollection.books} />
|
<BookGrid books={bookCollection.books} />
|
||||||
{:else}
|
{: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
|
library_id: stringCoerce
|
||||||
});
|
});
|
||||||
|
|
||||||
export const bookIdsSchema = z.object({
|
|
||||||
book_ids: stringArrayCoerce // TODO Could also set minimum length of 1
|
|
||||||
});
|
|
||||||
|
|
||||||
export const deleteBookFilesSchema = z.object({
|
export const deleteBookFilesSchema = z.object({
|
||||||
book_id: stringCoerce,
|
book_id: stringCoerce,
|
||||||
file_ids: stringArrayCoerce,
|
file_ids: stringArrayCoerce,
|
||||||
delete_files: z.boolean()
|
delete_files: z.boolean()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mirrors BookProgressCreate in backend/src/chitai/schemas/book.py
|
||||||
export const updateBookProgressSchema = z.object({
|
export const updateBookProgressSchema = z.object({
|
||||||
book_ids: stringArrayCoerce,
|
book_ids: stringArrayCoerce,
|
||||||
progress: number().min(0).max(1),
|
percentage: number().min(0).max(1),
|
||||||
completed: z.boolean().optional().default(false),
|
completed: z.boolean().optional().default(false),
|
||||||
epub_loc: z.string().optional(),
|
epub_cfi: z.string().optional(),
|
||||||
pdf_loc: z.string().optional()
|
epub_xpointer: z.string().optional(),
|
||||||
|
pdf_page: z.number().int().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type BookQuery = z.infer<typeof bookQuerySchema>;
|
export type BookQuery = z.infer<typeof bookQuerySchema>;
|
||||||
export type BookEditMetadata = z.infer<typeof editBookMetadataSchema>;
|
export type BookEditMetadata = z.infer<typeof editBookMetadataSchema>;
|
||||||
export type DeleteBook = z.infer<typeof deleteBooksSchema>;
|
export type DeleteBook = z.infer<typeof deleteBooksSchema>;
|
||||||
export type BookIds = z.infer<typeof bookIdsSchema>;
|
|
||||||
export type DeleteBookFiles = z.infer<typeof deleteBookFilesSchema>;
|
export type DeleteBookFiles = z.infer<typeof deleteBookFilesSchema>;
|
||||||
export type UpdateBookProgress = z.infer<typeof updateBookProgressSchema>;
|
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'),
|
name: z.string().min(1, 'Library must have a name'),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
root_path: z.string().min(1, 'Must specify a root path'),
|
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;
|
export type LibraryQuerySchema = typeof libraryQuerySchema;
|
||||||
|
|||||||
+87
-58
@@ -666,6 +666,17 @@ export interface components {
|
|||||||
device_type?: string | null;
|
device_type?: string | null;
|
||||||
device_id?: 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 */
|
||||||
BookRead: {
|
BookRead: {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -688,7 +699,7 @@ export interface components {
|
|||||||
series?: components["schemas"]["BookSeriesRead"] | null;
|
series?: components["schemas"]["BookSeriesRead"] | null;
|
||||||
series_position?: string | null;
|
series_position?: string | null;
|
||||||
files: components["schemas"]["FileMetadataRead"][];
|
files: components["schemas"]["FileMetadataRead"][];
|
||||||
progress?: unknown | null;
|
progress?: components["schemas"]["BookProgressRead"] | null;
|
||||||
};
|
};
|
||||||
/** BookSeriesRead */
|
/** BookSeriesRead */
|
||||||
BookSeriesRead: {
|
BookSeriesRead: {
|
||||||
@@ -742,6 +753,8 @@ export interface components {
|
|||||||
/** @default {author}/{title} */
|
/** @default {author}/{title} */
|
||||||
path_template: string | null;
|
path_template: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
/** @default library */
|
||||||
|
icon: string;
|
||||||
/** @default false */
|
/** @default false */
|
||||||
read_only: boolean;
|
read_only: boolean;
|
||||||
readonly slug: string;
|
readonly slug: string;
|
||||||
@@ -753,6 +766,7 @@ export interface components {
|
|||||||
root_path: string;
|
root_path: string;
|
||||||
path_template: string;
|
path_template: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
icon: string;
|
||||||
read_only: boolean;
|
read_only: boolean;
|
||||||
total?: number | null;
|
total?: number | null;
|
||||||
};
|
};
|
||||||
@@ -763,55 +777,6 @@ export interface components {
|
|||||||
refresh_token?: string | null;
|
refresh_token?: string | null;
|
||||||
expires_in?: number | 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 */
|
||||||
PublisherRead: {
|
PublisherRead: {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -972,7 +937,15 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
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 */
|
/** @description Bad request syntax or unsupported method */
|
||||||
@@ -1091,7 +1064,15 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
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 */
|
/** @description Bad request syntax or unsupported method */
|
||||||
@@ -1416,7 +1397,15 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
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 */
|
/** @description Bad request syntax or unsupported method */
|
||||||
@@ -1673,7 +1662,15 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
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 */
|
/** @description Bad request syntax or unsupported method */
|
||||||
@@ -1791,7 +1788,15 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
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 */
|
/** @description Bad request syntax or unsupported method */
|
||||||
@@ -1835,7 +1840,15 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
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 */
|
/** @description Bad request syntax or unsupported method */
|
||||||
@@ -1879,7 +1892,15 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
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 */
|
/** @description Bad request syntax or unsupported method */
|
||||||
@@ -2181,7 +2202,15 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
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[]) {
|
async markBooksAsComplete(bookIds: string[] | number[]) {
|
||||||
try {
|
try {
|
||||||
await this.updateBookProgress(bookIds, {
|
await this.updateBookProgress(bookIds, {
|
||||||
progress: 1,
|
percentage: 1,
|
||||||
completed: true
|
completed: true
|
||||||
});
|
});
|
||||||
|
|
||||||
if (bookIds.length > 1) toast.success('Book marked as complete!');
|
if (bookIds.length > 1) toast.success(`${bookIds.length} books marked as complete!`);
|
||||||
else toast.success(`${bookIds.length} books marked as complete!`);
|
else toast.success('Book marked as complete!');
|
||||||
|
|
||||||
// Get fresh data
|
// Get fresh data
|
||||||
await this.reload();
|
await this.reload();
|
||||||
@@ -119,12 +119,12 @@ export class BookOperationsState {
|
|||||||
async markBooksAsIncomplete(bookIds: number[]) {
|
async markBooksAsIncomplete(bookIds: number[]) {
|
||||||
try {
|
try {
|
||||||
await this.updateBookProgress(bookIds, {
|
await this.updateBookProgress(bookIds, {
|
||||||
progress: 0,
|
percentage: 0,
|
||||||
completed: false
|
completed: false
|
||||||
});
|
});
|
||||||
|
|
||||||
if (bookIds.length > 1) toast.success('Book progress reset!');
|
if (bookIds.length > 1) toast.success(`Reset progress for ${bookIds.length} books!`);
|
||||||
else toast.success(`Reset progress for ${bookIds.length} books!`);
|
else toast.success('Book progress reset!');
|
||||||
|
|
||||||
// get fresh data
|
// get fresh data
|
||||||
await this.reload();
|
await this.reload();
|
||||||
|
|||||||
Reference in New Issue
Block a user