5 Commits
Author SHA1 Message Date
patrick e898069b03 chore: update to ESLint 10
no-useless-assignment joined the recommended set and does not understand runes,
reading every `$bindable()` prop default as a dead store; off for Svelte files.
Carries @eslint/js, @eslint/compat, globals and @types/node with it.
2026-08-17 16:21:43 -04:00
patrick b33f57d942 chore: update frontend dependencies within their semver ranges
SvelteKit 2.53 to 2.70 renamed the remote form enhance callback's `form` to
`element`, which would have thrown on reset in the signup, device, library and
cover forms.
2026-08-17 16:13:42 -04:00
patrick 54043a97d2 feat: check formatting, linting and tests in CI
Every push runs ruff, prettier, pytest and svelte-check; a tagged release runs the
blocking half again before it publishes an image. eslint and svelte-check report
without failing, since 87 and 30 findings predate the workflow.
2026-08-17 15:17:20 -04:00
patrick 45b03764d2 chore: format the tree and clear ruff's findings
Applies ruff format, ruff check --fix and prettier, so CI can gate on all three.
The surviving unused imports were all re-exports in __init__.py, now covered by a
per-file ignore; the pdf.js viewer and the generated openapi types join the
vendored code that eslint and prettier already skip.
2026-08-17 15:17:14 -04:00
patrick b70ed5cb51 feat: build and publish release images from version tags
Tagging v* builds both images on Gitea Actions and pushes them to the instance
registry, then smoke-tests the published stack. The frontend read its backend
URL through import.meta.env, which Vite resolves at build time, so an image
could only point at whatever the build host had; it now reads it at runtime.
2026-08-17 15:05:18 -04:00
103 changed files with 2630 additions and 1985 deletions
+9
View File
@@ -1,6 +1,15 @@
# Change this secret to something random
CHITAI_TOKEN_SECRET=secret
# Which release to run. Pin this to a tag (e.g. 0.1.0) for a real deployment so a
# restart cannot silently move you to a newer image.
CHITAI_VERSION=latest
# The URL you reach the app on, exactly as it appears in the browser. Logging in fails
# with a 403 if this does not match, because SvelteKit rejects the form POST as
# cross-origin. Include the port unless it is the scheme default.
CHITAI_ORIGIN=http://localhost:3000
# Setup the path to your initial library (optional)
CHITAI_DEFAULT_LIBRARY_NAME=Books
CHITAI_DEFAULT_LIBRARY_PATH="libraries/books"
+80
View File
@@ -0,0 +1,80 @@
name: ci
# Formatting, linting, types and tests on every push and pull request.
#
# Blocking: the checks that are clean today — ruff format, ruff check, prettier, pytest.
# Non-blocking: eslint and svelte-check, which still report 87 and 30 pre-existing errors.
# Those need real code changes rather than a formatter, so they report without failing the
# build; drop the `continue-on-error` line from a step once its count reaches zero.
#
# The release workflow runs the blocking half again before it publishes anything.
on:
push:
branches: ['**']
pull_request:
jobs:
backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
# astral-sh/setup-uv is not mirrored on gitea.com, unlike the actions used elsewhere
# here, so install uv directly rather than depending on DEFAULT_ACTIONS_URL.
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install dependencies
run: uv sync --locked
- name: Format
run: uv run ruff format --check src/
- name: Lint
run: uv run ruff check src/
# pytest-databases starts a throwaway PostgreSQL container, so this needs a working
# Docker daemon on the runner — the same requirement the release workflow has.
- name: Tests
run: uv run pytest tests/ -q
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- name: Enable pnpm
run: corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Split out of `pnpm lint` (which is prettier && eslint) so formatting can block
# while eslint does not.
- name: Format
run: pnpm exec prettier --check .
- name: Lint
run: pnpm exec eslint .
continue-on-error: true
- name: Types
run: pnpm check
continue-on-error: true
+141
View File
@@ -0,0 +1,141 @@
name: release
# Builds and publishes the two container images from a version tag.
#
# Tagging v1.2.3 publishes chitai-backend and chitai-frontend as 1.2.3, 1.2, 1 and latest.
# A prerelease tag (v1.2.3-rc.1) publishes only 1.2.3-rc.1 and leaves latest alone, which
# makes -rc tags a safe way to exercise this workflow.
#
# Note that `uses: docker/...` does not mean github.com here the way it would on GitHub. Gitea
# resolves a bare reference against the instance's DEFAULT_ACTIONS_URL, which defaults to
# gitea.com; all five actions below are mirrored there at these tags. If that setting is ever
# pointed somewhere without them, set it to `github` in app.ini rather than editing this file.
#
# Requires a runner with a working Docker daemon (a docker:dind sidecar, or host mode with
# the socket mounted) and, for the smoke job, the compose plugin.
on:
push:
tags:
- 'v*'
env:
REGISTRY: git.jaroszew.ski
jobs:
# The blocking half of ci.yml, run again on the tagged commit so a release cannot publish
# an image whose tests fail. Deliberately duplicated rather than shared: Gitea's support
# for reusable workflows is thinner than GitHub's, and this is a dozen lines.
# Keep in step with ci.yml.
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Backend format, lint and tests
working-directory: backend
run: |
uv sync --locked
uv run ruff format --check src/
uv run ruff check src/
uv run pytest tests/ -q
- uses: actions/setup-node@v4
with:
node-version: 24
- name: Frontend format
working-directory: frontend
run: |
corepack enable
pnpm install --frozen-lockfile
pnpm exec prettier --check .
build:
needs: quality
runs-on: ubuntu-latest
strategy:
# The two images are independent artifacts; don't cancel a good build for a bad one.
fail-fast: false
matrix:
include:
- component: backend
context: ./backend
- component: frontend
context: ./frontend
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN || secrets.GITEA_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}-${{ matrix.component }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
labels: |
org.opencontainers.image.title=chitai-${{ matrix.component }}
org.opencontainers.image.source=https://git.jaroszew.ski/${{ github.repository }}
- uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Relies on the runner's cache server. If it is disabled, drop these two lines —
# a cold build of both images is only a few minutes.
cache-from: type=gha
cache-to: type=gha,mode=max
smoke:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN || secrets.GITEA_TOKEN }}
# Boots the stack from the images that were just pushed, rather than rebuilding them.
# This is what catches migrations failing from entrypoint.sh, the frontend being unable
# to reach the backend, and a missing runtime environment variable.
- name: Boot the published images
env:
TAG: ${{ github.ref_name }}
run: |
cp .env.prod-example .env
echo "CHITAI_VERSION=${TAG#v}" >> .env
mkdir -p libraries
docker compose pull backend frontend
docker compose up -d --wait --wait-timeout 180
curl -fsS http://localhost:8000/healthcheck
curl -fsS http://localhost:3000/login > /dev/null
- name: Tear down
if: always()
run: |
docker compose logs --no-color || true
docker compose down -v || true
+5
View File
@@ -103,6 +103,11 @@ API docs are served by the running backend at `http://localhost:8000/schema/` (S
manually against a live backend).
- **Migrations are mandatory.** The app runs with `create_all=False`, so a model change without a
matching Alembic revision will not reach the database.
- **CI gates formatting, linting and tests.** `.gitea/workflows/ci.yml` runs on every push and pull
request: `ruff format --check`, `ruff check`, `pytest`, and `prettier --check` all **block**;
`eslint` and `pnpm check` report without failing until their pre-existing counts reach zero. A
`v*` tag additionally builds and publishes both container images — see
`docs/ci-release-pipeline.md`.
- **Commit messages** follow `type: summary``feat:`, `fix:`, `refactor:`, `chore:`.
- The three `README.md` files are user-facing. Agent-facing knowledge belongs in the `AGENTS.md`
files.
+11 -5
View File
@@ -26,12 +26,18 @@ eBook library management application.
### Installation (Production Deployment)
1. Clone the repository: `git clone <repository_url>` (Replace `<repository_url>` with the actual URL)
1. Clone the repository: `git clone https://git.jaroszew.ski/patrick/chitai.git`
2. Navigate to the project root: `cd chitai`
3. Build the Docker images: `docker compose build`
4. Copy the example environment file and configure: `cp .env.prod-example .env`
5. Run the Docker containers in detached mode: `docker compose up -d`
6. Access the frontend at: `http://localhost:3000/`
3. Copy the example environment file: `cp .env.prod-example .env`
4. Edit `.env`. At minimum set `CHITAI_TOKEN_SECRET` to something random, and set
`CHITAI_ORIGIN` to the URL you will reach the app on — logging in fails with a 403 if it
does not match. Pin `CHITAI_VERSION` to a release tag if you would rather not track `latest`.
5. Pull the released images: `docker compose pull`
6. Run the Docker containers in detached mode: `docker compose up -d`
7. Access the frontend at: `http://localhost:3000/`
To build the images from source instead of pulling them, run `docker compose build` in place of
step 5.
## Development
+7 -7
View File
@@ -114,15 +114,15 @@ CMD ["litestar", "--app-dir", "chitai", "run", "--host", "0.0.0.0", "--port", "8
`litestar run` is the CLI development runner. Production should invoke uvicorn or granian
directly, with a worker count.
### Nothing gates formatting, linting or types
### No type checker on the backend
`ruff format --check src/` reports 27 of 61 files unformatted, and `ruff check src/` finds
114 errors — 100 of them unused imports, the rest bare `except`, unused variables and
`== True` comparisons. `ruff check --fix` clears 51 automatically.
Formatting, linting and tests now run in CI (`.gitea/workflows/ci.yml`) and block, and the
tree is clean against them. What is still missing is a type checker: nothing runs one despite
`# type: ignore` comments in the tree.
There is no `[tool.ruff]` section in `pyproject.toml`, so only ruff's default `E4/E7/E9/F`
rules run, and no type checker is configured at all despite `# type: ignore` comments in
the tree. Individually these are trivial; collectively they say nothing runs on commit.
`pyproject.toml` gained a `[tool.ruff.lint.per-file-ignores]` section for `__init__.py`
re-exports, but no rule selection — so only ruff's default `E4/E7/E9/F` rules run. Widening
that set is worthwhile and will surface a fresh batch of findings.
## Frontend
+5
View File
@@ -38,6 +38,11 @@ dev = [
"pytest-databases[postgres]>=0.15.0",
]
[tool.ruff.lint.per-file-ignores]
# The package __init__.py files exist to re-export their modules' public names, so every
# import in them is "unused" as far as F401 is concerned.
"__init__.py" = ["F401"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
+7 -3
View File
@@ -72,6 +72,7 @@ oauth2_auth = OAuth2PasswordBearerAuth[User](
watcher_task: asyncio.Task
@asynccontextmanager
async def setup_db_connection(app: Litestar) -> AsyncGenerator[None, None]:
# Setup databse
@@ -97,7 +98,7 @@ async def setup_db_connection(app: Litestar) -> AsyncGenerator[None, None]:
@asynccontextmanager
async def setup_directory_watcher(app: Litestar) -> AsyncGenerator[None, None]:
# Create book covers directory if it does not exist
await create_directory(settings.book_cover_path)
# Create consume directory
@@ -107,14 +108,17 @@ async def setup_directory_watcher(app: Litestar) -> AsyncGenerator[None, None]:
book_service = BookService(session=db_session)
library_service = LibraryService(session=db_session)
file_watcher = ConsumeDirectoryWatcher(settings.consume_path, library_service, book_service)
file_watcher = ConsumeDirectoryWatcher(
settings.consume_path, library_service, book_service
)
watcher_task = asyncio.create_task(file_watcher.init_watcher())
try:
yield
finally:
watcher_task.cancel()
def create_app() -> Litestar:
return Litestar(
route_handlers=[
+1 -1
View File
@@ -1,7 +1,7 @@
# src/chitai/controllers/access.py
# Standard library
from typing import Annotated, Any
from typing import Annotated
import logging
# Third-party libraries
+1 -2
View File
@@ -4,9 +4,8 @@
from typing import Annotated
# Third-party libraries
from litestar import Controller, post, get, patch, delete
from litestar import Controller, get
from litestar.params import Dependency
from litestar.exceptions import HTTPException
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
from advanced_alchemy.service.pagination import OffsetPagination
from advanced_alchemy.service import FilterTypeT
+30 -26
View File
@@ -8,47 +8,51 @@ from litestar import Controller, post, get, delete
from litestar.di import Provide
from chitai.services import dependencies as deps
class DeviceController(Controller):
""" Controller for managing KOReader devices."""
dependencies = {
"device_service": Provide(deps.provide_kosync_device_service)
}
class DeviceController(Controller):
"""Controller for managing KOReader devices."""
dependencies = {"device_service": Provide(deps.provide_kosync_device_service)}
path = "/devices"
@get()
async def get_devices(self, device_service: KosyncDeviceService, current_user: User) -> OffsetPagination[KosyncDeviceRead]:
""" Return a list of all the user's devices."""
devices = await device_service.list(
KosyncDevice.user_id == current_user.id
)
async def get_devices(
self, device_service: KosyncDeviceService, current_user: User
) -> OffsetPagination[KosyncDeviceRead]:
"""Return a list of all the user's devices."""
devices = await device_service.list(KosyncDevice.user_id == current_user.id)
return device_service.to_schema(devices, schema_type=KosyncDeviceRead)
@post()
async def create_device(self, data: KosyncDeviceCreate, device_service: KosyncDeviceService, current_user: User) -> KosyncDeviceRead:
device = await device_service.create({
'name': data.name,
'user_id': current_user.id
})
async def create_device(
self,
data: KosyncDeviceCreate,
device_service: KosyncDeviceService,
current_user: User,
) -> KosyncDeviceRead:
device = await device_service.create(
{"name": data.name, "user_id": current_user.id}
)
return device_service.to_schema(device, schema_type=KosyncDeviceRead)
@delete("/{device_id:int}")
async def delete_device(self, device_id: int, device_service: KosyncDeviceService, current_user: User) -> None:
async def delete_device(
self, device_id: int, device_service: KosyncDeviceService, current_user: User
) -> None:
# Ensure the device exists and is owned by the user
device = await device_service.get_one(
KosyncDevice.id == device_id,
KosyncDevice.user_id == current_user.id
KosyncDevice.id == device_id, KosyncDevice.user_id == current_user.id
)
await device_service.delete(device.id)
@get("/{device_id:int}/regenerate")
async def regenerate_device_api_key(self, device_id: int, device_service: KosyncDeviceService, current_user: User) -> KosyncDeviceRead:
async def regenerate_device_api_key(
self, device_id: int, device_service: KosyncDeviceService, current_user: User
) -> KosyncDeviceRead:
# Ensure the device exists and is owned by the user
device = await device_service.get_one(
KosyncDevice.id == device_id,
KosyncDevice.user_id == current_user.id
KosyncDevice.id == device_id, KosyncDevice.user_id == current_user.id
)
updated_device = await device_service.regenerate_api_key(device.id)
return device_service.to_schema(updated_device, schema_type=KosyncDeviceRead)
return device_service.to_schema(updated_device, schema_type=KosyncDeviceRead)
@@ -58,10 +58,14 @@ class KosyncController(Controller):
user: m.User,
) -> KosyncProgressRead:
"""Return the Kosync progress record associated with the given document."""
progress = await kosync_progress_service.get_by_document_hash(user.id, document_id)
progress = await kosync_progress_service.get_by_document_hash(
user.id, document_id
)
if not progress:
raise HTTPException(status_code=404, detail="No progress found for document")
raise HTTPException(
status_code=404, detail="No progress found for document"
)
return KosyncProgressRead(
document=progress.document,
@@ -85,6 +89,3 @@ class KosyncController(Controller):
detail="User accounts must be created via the main application",
status_code=HTTP_403_FORBIDDEN,
)
+4 -2
View File
@@ -10,7 +10,7 @@ from typing import Annotated
# Third-party libraries
import aiofiles
from aiofiles import os as aios
from litestar import Controller, post, get, patch, delete
from litestar import Controller, post, get, delete
from litestar.enums import RequestEncodingType
from litestar.params import Body, Dependency
from litestar.exceptions import HTTPException
@@ -89,7 +89,9 @@ class LibraryController(Controller):
Injected Dependencies:
library_service: The service used to query and return library data.
"""
results, total = await library_service.list_and_count(*filters, load=[m.Library.books])
results, total = await library_service.list_and_count(
*filters, load=[m.Library.books]
)
return library_service.to_schema(
results, total, filters, schema_type=LibraryRead
)
+117 -103
View File
@@ -1,4 +1,3 @@
from chitai.services import dependencies as deps
from chitai.database import models as m
from chitai.services.author import AuthorService
@@ -6,7 +5,15 @@ from chitai.services.filters.author import AuthorLibraryFilter
from chitai.services.filters.publisher import PublisherLibraryFilter
from chitai.services.filters.tags import TagLibraryFilter
from chitai.services.opds.models import Entry, Link, LinkTypes, LinkRelations
from chitai.services.opds.opds import create_acquisition_feed, create_navigation_feed, create_library_navigation_feed, create_collection_navigation_feed, create_pagination_links, create_search_link, get_opensearch_document
from chitai.services.opds.opds import (
create_acquisition_feed,
create_navigation_feed,
create_library_navigation_feed,
create_collection_navigation_feed,
create_pagination_links,
create_search_link,
get_opensearch_document,
)
from chitai.services import BookService, ShelfService, LibraryService
from chitai.services.publisher import PublisherService
from chitai.services.tag import TagService
@@ -14,7 +21,7 @@ from litestar import Controller, Request, Response, get
from litestar.response import File
from litestar.di import Provide
from litestar.exceptions import HTTPException
from advanced_alchemy.filters import CollectionFilter, LimitOffset, OrderBy
from advanced_alchemy.filters import CollectionFilter, LimitOffset, OrderBy
from chitai.middleware.basic_auth import basic_auth_mw
from urllib.parse import urlencode
from typing import Annotated
@@ -22,11 +29,10 @@ from litestar.params import Dependency
from advanced_alchemy.service import FilterTypeT
class OpdsController(Controller):
""" Controller for managing OPDS endpoints """
"""Controller for managing OPDS endpoints"""
middleware=[basic_auth_mw]
middleware = [basic_auth_mw]
dependencies = {
"user": Provide(deps.provide_user_via_basic_auth),
@@ -76,32 +82,30 @@ class OpdsController(Controller):
title=lib.name,
href=f"/opds/library/{lib.id}",
rel=LinkRelations.SUBSECTION,
type=LinkTypes.NAVIGATION
type=LinkTypes.NAVIGATION,
)
]
) for lib in libraries
],
)
for lib in libraries
]
feed = create_navigation_feed(
id="/opds",
title="Root",
self_url="/opds",
links=[
Link(
rel="search",
href="/opds/opensearch",
type="application/opensearchdescription+xml",
title="Search books",
)
],
entries=entries
)
return Response(
feed,
media_type="application/xml"
id="/opds",
title="Root",
self_url="/opds",
links=[
Link(
rel="search",
href="/opds/opensearch",
type="application/opensearchdescription+xml",
title="Search books",
)
],
entries=entries,
)
return Response(feed, media_type="application/xml")
@get("/acquisition")
async def get_acquisition_feed(
self,
@@ -109,37 +113,41 @@ class OpdsController(Controller):
feed_id: str,
feed_title: str,
books_service: BookService,
book_filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [],
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = []
book_filters: Annotated[
list[FilterTypeT], Dependency(skip_validation=True)
] = [],
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [],
) -> Response:
all_filters = [*filters, *book_filters]
books, total = await books_service.list_and_count(*all_filters)
limit, offset = extract_limit_offset(all_filters)
links = []
# Create pagination links if it is a paginated feed
if request.query_params.get('paginated'):
if request.query_params.get("paginated"):
pagination = create_pagination_links(
request=request,
total=total,
limit=limit,
offset=offset,
feed_title=feed_title,
link_type=LinkTypes.ACQUISITION
link_type=LinkTypes.ACQUISITION,
)
links.extend([link for link in [pagination.next_link, pagination.prev_link] if link])
links.extend(
[link for link in [pagination.next_link, pagination.prev_link] if link]
)
# Add search link if this is a searchable feed
if request.query_params.get('search'):
if request.query_params.get("search"):
links.append(create_search_link(request))
# Create self URL
self_url = f"{request.url.path}?{urlencode(list(request.query_params.items()), doseq=True)}"
feed = create_acquisition_feed(
id=feed_id,
title=feed_title,
@@ -147,28 +155,26 @@ class OpdsController(Controller):
books=books,
links=links,
)
return Response(feed, media_type="application/xml")
return Response(feed, media_type="application/xml")
@get("/opensearch")
async def opensearch(self, user: m.User, request: Request) -> Response:
return Response(
get_opensearch_document(
base_url=f'/opds/search?{urlencode(list(request.query_params.items()), doseq=True)}&'
base_url=f"/opds/search?{urlencode(list(request.query_params.items()), doseq=True)}&"
),
media_type="application/xml"
media_type="application/xml",
)
@get("/library/{library_id:int}")
async def get_library_feed(self, library: m.Library) -> Response:
feed = create_library_navigation_feed(library)
return Response(feed, media_type="application/xml")
@get("/library/{library_id:int}/{collection_type:str}")
async def get_library_collection_feed(
self,
@@ -180,39 +186,51 @@ class OpdsController(Controller):
tag_service: TagService,
publisher_service: PublisherService,
request: Request,
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = []
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [],
) -> Response:
service_map = {
'shelves': (shelf_service, lambda: shelf_service.list_and_count(
*filters,
CollectionFilter("library_id", values=[library.id]),
OrderBy("name", "asc"),
m.BookList.user_id == user.id
)),
'tags': (tag_service, lambda: tag_service.list_and_count(
*filters,
TagLibraryFilter(libraries=[library.id]),
OrderBy("name", "asc"),
uniquify=True,
)),
'authors': (author_service, lambda: author_service.list_and_count(
*filters,
AuthorLibraryFilter(libraries=[library.id]),
OrderBy("name", "asc"),
uniquify=True
)),
'publishers': (publisher_service, lambda: publisher_service.list_and_count(
*filters,
PublisherLibraryFilter(libraries=[library.id]),
OrderBy("name", "asc"),
uniquify=True
))
"shelves": (
shelf_service,
lambda: shelf_service.list_and_count(
*filters,
CollectionFilter("library_id", values=[library.id]),
OrderBy("name", "asc"),
m.BookList.user_id == user.id,
),
),
"tags": (
tag_service,
lambda: tag_service.list_and_count(
*filters,
TagLibraryFilter(libraries=[library.id]),
OrderBy("name", "asc"),
uniquify=True,
),
),
"authors": (
author_service,
lambda: author_service.list_and_count(
*filters,
AuthorLibraryFilter(libraries=[library.id]),
OrderBy("name", "asc"),
uniquify=True,
),
),
"publishers": (
publisher_service,
lambda: publisher_service.list_and_count(
*filters,
PublisherLibraryFilter(libraries=[library.id]),
OrderBy("name", "asc"),
uniquify=True,
),
),
}
if collection_type not in service_map:
raise HTTPException(status_code=404, detail="Collection type not found")
_, fetch_items = service_map[collection_type]
items, total = await fetch_items()
links = []
@@ -220,41 +238,40 @@ class OpdsController(Controller):
# Create pagination links if it is a paginated feed
limit, offset = extract_limit_offset(filters)
if request.query_params.get('paginated'):
if request.query_params.get("paginated"):
pagination = create_pagination_links(
request=request,
total=total,
limit=limit,
offset=offset,
feed_title=collection_type,
link_type=LinkTypes.ACQUISITION
link_type=LinkTypes.ACQUISITION,
)
links.extend([link for link in [pagination.next_link, pagination.prev_link] if link])
links.extend(
[link for link in [pagination.next_link, pagination.prev_link] if link]
)
feed = create_collection_navigation_feed(library, collection_type, items, links)
return Response(feed, media_type="application/xml")
@get("/search")
async def search_books(
self, books_service: BookService,
self,
books_service: BookService,
request: Request,
book_filters: Annotated[
list[FilterTypeT], Dependency(skip_validation=True)
] = [],
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = []
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [],
) -> Response:
filters = [*filters, *book_filters]
books, total = await books_service.list_and_count(
*filters
)
books, total = await books_service.list_and_count(*filters)
limit, offset = extract_limit_offset(filters)
# Create pagination links
pagination = create_pagination_links(
request=request,
@@ -262,37 +279,34 @@ class OpdsController(Controller):
limit=limit,
offset=offset,
feed_title="Search Results",
link_type=LinkTypes.ACQUISITION
link_type=LinkTypes.ACQUISITION,
)
links = [link for link in [pagination.next_link, pagination.prev_link] if link]
catalog_xml = create_acquisition_feed(
id=f"/opds/search?q=q",
id="/opds/search?q=q",
title="Search results",
url=f"/opds/search?q=q",
url="/opds/search?q=q",
books=books,
links=links
links=links,
)
return Response(catalog_xml, media_type="application/xml")
@get(path="download/{book_id:int}/{file_id:int}")
async def get_file(
self, book_id: int, file_id: int, books_service: BookService
) -> File:
return await books_service.get_file(book_id, file_id)
def extract_limit_offset(filters: list[FilterTypeT]) -> tuple[int, int]:
"""Extract page size and offset from filters"""
limit_offset_filter = next(
(f for f in filters if isinstance(f, LimitOffset)),
None
)
limit_offset_filter = next((f for f in filters if isinstance(f, LimitOffset)), None)
if limit_offset_filter:
return limit_offset_filter.limit, limit_offset_filter.offset
raise ValueError("LimitOffset filter not found")
raise ValueError("LimitOffset filter not found")
+1 -1
View File
@@ -4,7 +4,7 @@
from typing import Annotated
# Third-party libraries
from litestar import Controller, post, get, patch, delete
from litestar import Controller, get
from litestar.params import Dependency
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
from advanced_alchemy.service.pagination import OffsetPagination
+3 -1
View File
@@ -70,7 +70,9 @@ class BookshelfController(Controller):
filters.append(CollectionFilter("library_id", values=libraries))
filters.append(m.BookList.user_id == current_user.id)
results, total = await shelf_service.list_and_count(*filters, load=[selectinload(m.BookList.book_links)])
results, total = await shelf_service.list_and_count(
*filters, load=[selectinload(m.BookList.book_links)]
)
return shelf_service.to_schema(results, total, filters, schema_type=ShelfRead)
@post()
+1 -1
View File
@@ -4,7 +4,7 @@
from typing import Annotated
# Third-party libraries
from litestar import Controller, post, get, patch, delete
from litestar import Controller, get
from litestar.params import Dependency
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
from advanced_alchemy.service.pagination import OffsetPagination
@@ -34,7 +34,7 @@ class BookList(BigIntAuditBase):
return len(self.book_links) if self.book_links else 0
except Exception:
return None
class BookListLink(BigIntBase):
__tablename__ = "book_list_links"
@@ -1,7 +1,6 @@
from typing import Optional
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import relationship
from advanced_alchemy.base import BigIntAuditBase
@@ -20,5 +19,5 @@ class BookProgress(BigIntAuditBase):
pdf_page: Mapped[Optional[int]]
percentage: Mapped[float]
completed: Mapped[Optional[bool]]
device: Mapped[Optional[str]] # Device that updated the progress
device: Mapped[Optional[str]] # Device that updated the progress
device_id: Mapped[Optional[str]]
@@ -1,12 +1,10 @@
from collections.abc import Hashable
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import ColumnElement, ForeignKey
from sqlalchemy import ColumnElement
from advanced_alchemy.base import BigIntAuditBase
from advanced_alchemy.mixins import UniqueMixin
from .book import Book
class BookSeries(BigIntAuditBase, UniqueMixin):
__tablename__ = "book_series"
@@ -1,9 +1,10 @@
from sqlalchemy import ColumnElement, ForeignKey
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from advanced_alchemy.base import BigIntAuditBase
class KosyncDevice(BigIntAuditBase):
__tablename__ = "devices"
@@ -15,7 +15,7 @@ class Library(BigIntAuditBase, SlugKey):
name: Mapped[str] = mapped_column(unique=True)
root_path: Mapped[str]
# 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]]
icon: Mapped[str] = mapped_column(default="library")
read_only: Mapped[bool] = mapped_column(nullable=False, default=False)
@@ -1,6 +1,4 @@
from litestar import Request, Response, MediaType
from litestar.exceptions import HTTPException
from litestar.status_codes import HTTP_404_NOT_FOUND
from advanced_alchemy.exceptions import NotFoundError
+17 -12
View File
@@ -1,9 +1,9 @@
from base64 import b64decode
from chitai.services.user import UserService
from litestar.middleware import (
AbstractAuthenticationMiddleware,
AuthenticationResult,
DefineMiddleware
AbstractAuthenticationMiddleware,
AuthenticationResult,
DefineMiddleware,
)
from litestar.connection import ASGIConnection
from litestar.exceptions import NotAuthorizedException, PermissionDeniedException
@@ -11,25 +11,30 @@ from chitai.config import settings
class BasicAuthenticationMiddleware(AbstractAuthenticationMiddleware):
async def authenticate_request(self, connection: ASGIConnection) -> AuthenticationResult:
"""Given a request, parse the header for Base64 encoded basic auth credentials. """
async def authenticate_request(
self, connection: ASGIConnection
) -> AuthenticationResult:
"""Given a request, parse the header for Base64 encoded basic auth credentials."""
# retrieve the auth header
auth_header = connection.headers.get("Authorization", None)
if not auth_header:
raise NotAuthorizedException()
username, password = b64decode(auth_header.split("Basic ")[1]).decode().split(":")
username, password = (
b64decode(auth_header.split("Basic ")[1]).decode().split(":")
)
try:
db_session = settings.alchemy_config.provide_session(connection.app.state, connection.scope)
db_session = settings.alchemy_config.provide_session(
connection.app.state, connection.scope
)
user_service = UserService(db_session)
user = await user_service.authenticate(username, password)
return AuthenticationResult(user=user, auth=None)
except PermissionDeniedException:
raise NotAuthorizedException()
basic_auth_mw = DefineMiddleware(BasicAuthenticationMiddleware)
basic_auth_mw = DefineMiddleware(BasicAuthenticationMiddleware)
+15 -11
View File
@@ -1,9 +1,9 @@
from chitai.services.user import UserService
from chitai.services.kosync_device import KosyncDeviceService
from litestar.middleware import (
AbstractAuthenticationMiddleware,
AuthenticationResult,
DefineMiddleware
AbstractAuthenticationMiddleware,
AuthenticationResult,
DefineMiddleware,
)
from litestar.connection import ASGIConnection
from litestar.exceptions import NotAuthorizedException, PermissionDeniedException
@@ -11,19 +11,23 @@ from chitai.config import settings
class KosyncAuthenticationMiddleware(AbstractAuthenticationMiddleware):
async def authenticate_request(self, connection: ASGIConnection) -> AuthenticationResult:
"""Given a request, parse the header for Base64 encoded basic auth credentials. """
async def authenticate_request(
self, connection: ASGIConnection
) -> AuthenticationResult:
"""Given a request, parse the header for Base64 encoded basic auth credentials."""
# retrieve the auth header
api_key = connection.headers.get("X-AUTH-USER", None)
if not api_key:
raise NotAuthorizedException()
try:
db_session = settings.alchemy_config.provide_session(connection.app.state, connection.scope)
db_session = settings.alchemy_config.provide_session(
connection.app.state, connection.scope
)
user_service = UserService(db_session)
device_service = KosyncDeviceService(db_session)
device = await device_service.get_by_api_key(api_key)
user = await user_service.get(device.user_id)
@@ -32,6 +36,6 @@ class KosyncAuthenticationMiddleware(AbstractAuthenticationMiddleware):
except PermissionDeniedException as exc:
print(exc)
raise NotAuthorizedException()
kosync_api_key_auth = DefineMiddleware(KosyncAuthenticationMiddleware)
kosync_api_key_auth = DefineMiddleware(KosyncAuthenticationMiddleware)
-2
View File
@@ -273,5 +273,3 @@ class BookProgressCreate(BaseModel):
completed: bool | None = None
device_type: str | None = None
device_id: str | None = None
+3 -2
View File
@@ -1,8 +1,8 @@
from pathlib import Path
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field, SkipValidation, computed_field
from litestar.datastructures import UploadFile
from advanced_alchemy.utils.text import slugify
from advanced_alchemy.utils.text import slugify
class LibraryCreate(BaseModel):
name: Annotated[str, Field(min_length=1)]
@@ -17,6 +17,7 @@ class LibraryCreate(BaseModel):
def slug(self) -> str:
return slugify(self.name)
class LibraryRead(BaseModel):
id: int
name: str
+2 -2
View File
@@ -1,11 +1,11 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, Field, field_validator
class ShelfRead(BaseModel):
id: int
title: str
library_id: int | None = None
total: int | None = None # Number of books in the shelf
total: int | None = None # Number of books in the shelf
class ShelfCreate(BaseModel):
+84 -50
View File
@@ -19,7 +19,7 @@ from advanced_alchemy.service import (
SQLAlchemyAsyncRepositoryService,
ModelDictT,
is_dict,
schema_dump
schema_dump,
)
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
from advanced_alchemy.filters import CollectionFilter
@@ -349,7 +349,9 @@ def _series_position(data: Any) -> tuple[str, str | None]:
return normalize_title(series), (position or "").strip() or None
def _is_different_volume(left: tuple[str, str | None], right: tuple[str, str | None]) -> bool:
def _is_different_volume(
left: tuple[str, str | None], right: tuple[str, str | None]
) -> bool:
"""
Whether two books are numbered entries of one series, and not the same entry.
@@ -464,8 +466,6 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
repository_type = Repo
async def create_book(
self,
data: ModelDictT[Book],
@@ -647,7 +647,10 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
return []
statement = (
select(Book).where(or_(*conditions)).options(*self._MATCH_LOADS).order_by(Book.id)
select(Book)
.where(or_(*conditions))
.options(*self._MATCH_LOADS)
.order_by(Book.id)
)
if exclude_book_id is not None:
@@ -724,7 +727,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
ValueError: If fewer than two distinct books were named, one of them does
not exist, or they do not all belong to one library.
"""
merged_ids = [book_id for book_id in dict.fromkeys(merged_ids) if book_id != survivor_id]
merged_ids = [
book_id for book_id in dict.fromkeys(merged_ids) if book_id != survivor_id
]
if not merged_ids:
raise ValueError("A merge needs at least two different books")
@@ -848,12 +853,16 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# Reading progress is per user, and the furthest one is the true answer for a
# reader who has been through the EPUB and not the PDF.
rows = (
await session.execute(
select(BookProgress)
.where(BookProgress.book_id.in_([survivor_id, *merged_ids]))
.order_by(BookProgress.percentage.desc())
(
await session.execute(
select(BookProgress)
.where(BookProgress.book_id.in_([survivor_id, *merged_ids]))
.order_by(BookProgress.percentage.desc())
)
)
).scalars().all()
.scalars()
.all()
)
furthest: dict[int, BookProgress] = {}
for progress in rows:
@@ -902,14 +911,19 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# and only one of them however many books offered it.
held_names = select(Identifier.name).where(Identifier.book_id == survivor_id)
incoming = (
await session.execute(
select(Identifier)
.where(
Identifier.book_id.in_(merged_ids), Identifier.name.notin_(held_names)
(
await session.execute(
select(Identifier)
.where(
Identifier.book_id.in_(merged_ids),
Identifier.name.notin_(held_names),
)
.order_by(Identifier.book_id, Identifier.id)
)
.order_by(Identifier.book_id, Identifier.id)
)
).scalars().all()
.scalars()
.all()
)
taken: set[str] = set()
for identifier in incoming:
@@ -932,15 +946,19 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
merged = set(merged_ids)
rows = (
await session.execute(
select(DuplicateDismissal).where(
or_(
DuplicateDismissal.book_a_id.in_(merged_ids),
DuplicateDismissal.book_b_id.in_(merged_ids),
(
await session.execute(
select(DuplicateDismissal).where(
or_(
DuplicateDismissal.book_a_id.in_(merged_ids),
DuplicateDismissal.book_b_id.in_(merged_ids),
)
)
)
)
).scalars().all()
.scalars()
.all()
)
existing = await self._dismissed_pairs()
doomed: list[int] = []
@@ -1025,7 +1043,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# copies meet as long as they agree on any one of them.
for title in titles:
for author in authors:
buckets[(MATCHED_ON_TITLE_AUTHOR, f"{title}\x00{author}")].append(book_id)
buckets[(MATCHED_ON_TITLE_AUTHOR, f"{title}\x00{author}")].append(
book_id
)
dismissed = await self._dismissed_pairs()
series = {book_id: _series_position(book) for book_id, book in books.items()}
@@ -1071,8 +1091,14 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
raise ValueError("A book cannot be dismissed against itself")
found = (
await self.repository.session.execute(select(Book.id).where(Book.id.in_(pair)))
).scalars().all()
(
await self.repository.session.execute(
select(Book.id).where(Book.id.in_(pair))
)
)
.scalars()
.all()
)
if len(set(found)) != 2:
raise ValueError("No such book")
@@ -1305,7 +1331,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
**kwargs,
)
result.books.append(book)
await self._record_possible_duplicates(result.possible_duplicates, book, library)
await self._record_possible_duplicates(
result.possible_duplicates, book, library
)
return result
@@ -1344,7 +1372,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
consume_path: Path,
library: Library,
allow_duplicates: bool = False,
**kwargs
**kwargs,
) -> ImportResult:
"""
Import files that are already on disk, from the consume directory.
@@ -1368,7 +1396,6 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
result = ImportResult()
file_groups: dict[Path, list[Path]] = defaultdict(list)
for file_path in file_paths:
rel_path = file_path.relative_to(consume_path)
parent_rel = rel_path.parent
@@ -1379,11 +1406,10 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
group_key = rel_path # Use the file itself as the key
else:
group_key = parent_rel # Use parent directory as key
# Add to appropriate group
file_groups[group_key].append(file_path)
# For each grouping
for group, files in file_groups.items():
# Fingerprinted before anything moves, since the paths are about to change.
@@ -1405,7 +1431,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
cleanup_empty_parent_directories(consume_path / group, consume_path)
continue
data: dict[str, Any] = {'files': accepted}
data: dict[str, Any] = {"files": accepted}
await self._parse_metadata_from_files(data, root_path=consume_path)
await self._save_cover_image(data)
@@ -1446,7 +1472,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
book = await super().create(data)
result.books.append(book)
await self._record_possible_duplicates(result.possible_duplicates, book, library)
await self._record_possible_duplicates(
result.possible_duplicates, book, library
)
await self.repository.session.commit()
@@ -1554,17 +1582,11 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# What the catalogue claims and what is on disk can disagree: Calibre keeps the
# row when a file is moved away behind its back.
present = [
file.path
for file in entry.files
if await aios.path.isfile(file.path)
file.path for file in entry.files if await aios.path.isfile(file.path)
]
if not present:
reason = (
"no files on disk"
if entry.files
else "no files in the catalogue"
)
reason = "no files on disk" if entry.files else "no files in the catalogue"
result.skipped.append(
UnimportedBook(
calibre_id=entry.calibre_id, title=entry.title, reason=reason
@@ -1918,9 +1940,13 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# TODO: Move only the files associated with the book instead of the whole directory
await move_dir_contents(book.path, updated_path)
data["path"] = str(updated_path)
cleanup_empty_parent_directories(Path(book.path), Path(library.root_path))
cleanup_empty_parent_directories(
Path(book.path), Path(library.root_path)
)
return await super().update(data, item_id=book_id, execution_options={"populate_existing": True})
return await super().update(
data, item_id=book_id, execution_options={"populate_existing": True}
)
async def add_files(
self,
@@ -1979,7 +2005,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
data["files"] = files
new_files = await self._save_book_files(library, data, fingerprints)
book.files.extend(new_files)
await self.update_book(book.id, {"files": [file for file in book.files]}, library)
await self.update_book(
book.id, {"files": [file for file in book.files]}, library
)
@staticmethod
async def _restore_file(
@@ -2067,22 +2095,24 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
model_data = await self._populate_with_unique_relationships(data)
return model_data
def _preprocess_book_data(self, data: dict) -> dict:
"""Transform API input format to model format."""
if not isinstance(data, dict):
return data
# Transform dict identifiers to list of Identifier objects
if "identifiers" in data and isinstance(data["identifiers"], dict):
data["identifiers"] = [
Identifier(name=key, value=val)
for key, val in data["identifiers"].items()
]
return data
async def _populate_with_unique_relationships(self, data: ModelDictT[Book]) -> ModelDictT[Book]:
async def _populate_with_unique_relationships(
self, data: ModelDictT[Book]
) -> ModelDictT[Book]:
"""
Ensure relationship entities (authors, series, tags, etc.) are unique in the database.
@@ -2307,7 +2337,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
data["files"] = file_metadata
return data["files"]
async def _parse_metadata_from_files(self, data: dict, root_path: Path | None = None) -> dict:
async def _parse_metadata_from_files(
self, data: dict, root_path: Path | None = None
) -> dict:
"""
Extract metadata (title, author, etc.) from book files.
@@ -2319,7 +2351,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
Returns:
The data with extracted metadata populated in empty fields.
"""
extracted_metadata = await MetadataExtractor.extract_metadata(data["files"], root_path)
extracted_metadata = await MetadataExtractor.extract_metadata(
data["files"], root_path
)
# Add missing fields and update empty (falsey) fields with extracted metadata
for attr in extracted_metadata.keys():
-5
View File
@@ -1,13 +1,8 @@
# src/chitai/services/bookshelf.py
# Third-party libraries
from typing import Any, Sequence
from advanced_alchemy.exceptions import ErrorMessages
from advanced_alchemy.filters import StatementFilter
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
from advanced_alchemy.utils.dataclass import Empty, EmptyType
from sqlalchemy import ColumnElement, Select, delete
# Local imports
from chitai.database.models.book_list import BookList, BookListLink
+19 -4
View File
@@ -297,7 +297,9 @@ class CalibreLibrary:
async def count(self) -> int:
"""How many books the catalogue holds, without reading any of them."""
rows = await self._in_thread(lambda: self._execute("SELECT count(*) FROM books"))
rows = await self._in_thread(
lambda: self._execute("SELECT count(*) FROM books")
)
return int(rows[0][0])
async def books(self) -> list[CalibreBook]:
@@ -393,7 +395,9 @@ class CalibreLibrary:
calibre_id=book_id,
uuid=str(uuid or ""),
title=str(title or ""),
authors=[unescape_author(name) for name in authors.get(book_id, [])],
authors=[
unescape_author(name) for name in authors.get(book_id, [])
],
description=strip_html(descriptions.get(book_id)),
published_date=parse_date(pubdate),
series=in_series,
@@ -510,8 +514,19 @@ class _TextExtractor(HTMLParser):
# a description of three paragraphs comes out as one run-on sentence.
_BREAKS = frozenset(
{
"p", "br", "div", "li", "tr", "blockquote", "hr",
"h1", "h2", "h3", "h4", "h5", "h6",
"p",
"br",
"div",
"li",
"tr",
"blockquote",
"hr",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
}
)
+25 -21
View File
@@ -4,17 +4,23 @@ from collections import defaultdict
from chitai.config import settings
from chitai.database.models.library import Library
from chitai.services import BookService, LibraryService
from chitai.services.metadata_extractor import Extractor
from chitai.services.utils import create_directory
from watchfiles import awatch, Change
class ConsumeDirectoryWatcher:
"""Watches a directory and batch processes files by their relative path."""
def __init__(self, watch_path: str, library_service: LibraryService, book_service: BookService, batch_delay: float = 3.0):
def __init__(
self,
watch_path: str,
library_service: LibraryService,
book_service: BookService,
batch_delay: float = 3.0,
):
"""
Initialize the file watcher.
Args:
watch_path: Directory path to watch
batch_delay: Seconds to wait before processing a batch
@@ -41,9 +47,9 @@ class ConsumeDirectoryWatcher:
for change_type, file_path in changes:
if change_type != Change.added:
continue
file_path = Path(file_path)
# If a directory was added, scan it for existing files
if file_path.is_dir():
print(f"Directory added: {file_path}")
@@ -51,28 +57,28 @@ class ConsumeDirectoryWatcher:
else:
print(f"File added: {file_path}")
await self._handle_file_added(file_path)
except asyncio.CancelledError:
print("File watcher stopped")
# Wait for any pending processing tasks
if self._processing_tasks:
await asyncio.gather(*self._processing_tasks, return_exceptions=True)
raise
async def _handle_file_added(self, file_path: Path):
"""Handle a single file being added."""
# Get relative path from watch directory
rel_path = file_path.relative_to(self.watch_path)
parent_rel = rel_path.parent
library = parent_rel.parts[0]
# Add to appropriate group
self.file_groups[library].add(file_path)
# Schedule batch processing for this group
self._schedule_batch_processing(library)
async def _handle_directory_added(self, dir_path: Path):
"""Handle a directory being added - scan it for existing files."""
try:
@@ -83,35 +89,34 @@ class ConsumeDirectoryWatcher:
await self._handle_file_added(file_path)
except Exception as e:
print(f"Error scanning directory {dir_path}: {e}")
def _schedule_batch_processing(self, library_slug: str):
"""Schedule batch processing for a specific path group."""
# Create a task to process this group after a delay
task = asyncio.create_task(self._delayed_batch_process(library_slug))
self._processing_tasks.add(task)
task.add_done_callback(self._processing_tasks.discard)
async def _delayed_batch_process(self, library_slug: str):
"""Wait for batch delay, then process accumulated files."""
await asyncio.sleep(self.batch_delay)
# Get and clear the file list for this path
if library_slug not in self.file_groups:
return
files_to_process = self.file_groups[library_slug].copy()
self.file_groups[library_slug].clear()
if not files_to_process:
return
print(f"Batch processing {len(files_to_process)} files from {library_slug}")
await self._process_batch(files_to_process, library_slug)
async def _process_batch(self, file_paths: set[Path], library_slug: str):
"""Process a batch of files."""
try:
result = await self.book_service.create_many_from_existing_files(
list(file_paths),
self.watch_path / Path(library_slug),
@@ -138,10 +143,9 @@ class ConsumeDirectoryWatcher:
f"already be in the library as: {names}"
)
except Exception as e:
print(f"Error processing batch: {e}")
raise e
async def _get_library(self, slug: str) -> Library:
return await self.library_service.get_one(Library.slug == slug)
+2 -5
View File
@@ -2,7 +2,7 @@
# Standard library
from __future__ import annotations
from typing import Any, AsyncGenerator, Callable, NotRequired, Optional
from typing import Any, AsyncGenerator, Optional
# Third-party libraries
from advanced_alchemy.extensions.litestar.providers import (
@@ -13,7 +13,6 @@ from advanced_alchemy.extensions.litestar.providers import (
)
from advanced_alchemy.exceptions import NotFoundError
from advanced_alchemy.filters import CollectionFilter, StatementFilter
from advanced_alchemy.service import FilterTypeT
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from litestar import Request
@@ -25,7 +24,6 @@ from litestar.di import Provide
from advanced_alchemy.extensions.litestar.providers import create_filter_dependencies
# Local imports
from chitai import schemas as s
from chitai.database import models as m
from chitai.services import (
UserService,
@@ -154,7 +152,6 @@ def create_book_filter_dependencies(
# OVERRIDE: Custom search filter with trigram search
if config.get("search"):
search_fields = config.get("search")
def provide_trigram_search_filter(
search_string: str | None = Parameter(
@@ -370,6 +367,7 @@ def provide_optional_user(request: Request[m.User, Token, Any]) -> m.User | None
return None
async def provide_user_via_basic_auth(request: Request[m.User, None, Any]) -> m.User:
return request.user
@@ -381,4 +379,3 @@ async def provide_user_via_kosync_auth(request: Request[m.User, None, Any]) -> m
provide_kosync_device_service = create_service_provider(KosyncDeviceService)
provide_kosync_progress_service = create_service_provider(KosyncProgressService)
@@ -4,9 +4,7 @@ from pathlib import Path
import re
from jinja2 import Template
from advanced_alchemy.service import ModelDictT
import chitai.database.models as m
# TODO: Replace Jinja2 templates with a simpler custom templating system.
# Current Jinja2 implementation is overly complex for basic path generation.
@@ -1,4 +1,4 @@
from typing import Any, Optional
from typing import Optional
from dataclasses import dataclass
from advanced_alchemy.filters import (
+9 -5
View File
@@ -3,11 +3,10 @@ from typing import Any, Optional
from dataclasses import dataclass, field
from sqlalchemy.orm import aliased
from sqlalchemy import Select, and_, desc, func, or_, text
from sqlalchemy import Select, and_, func, or_, text
from advanced_alchemy.filters import (
StatementTypeT,
StatementFilter,
CollectionFilter,
ModelT,
)
@@ -135,7 +134,7 @@ class ProgressFilter(StatementFilter):
status_conditions.append(
and_(
or_(
m.BookProgress.completed == False,
m.BookProgress.completed.is_(False),
m.BookProgress.completed.is_(None),
),
m.BookProgress.percentage > 0,
@@ -143,7 +142,7 @@ class ProgressFilter(StatementFilter):
)
if ProgressStatus.READ in self.statuses:
status_conditions.append(m.BookProgress.completed == True)
status_conditions.append(m.BookProgress.completed.is_(True))
if ProgressStatus.UNREAD in self.statuses:
status_conditions.append(m.BookProgress.id.is_(None))
@@ -154,6 +153,7 @@ class ProgressFilter(StatementFilter):
@dataclass
class FileFilter(StatementFilter):
"""Filter books that are related to the given files."""
file_ids: list[int]
def append_to_statement(
@@ -165,17 +165,21 @@ class FileFilter(StatementFilter):
return super().append_to_statement(statement, model, *args, **kwargs)
@dataclass
class FileHashFilter(StatementFilter):
file_hashes: list[str]
def append_to_statement(self, statement: StatementTypeT, model: type[ModelT], *args, **kwargs) -> StatementTypeT:
def append_to_statement(
self, statement: StatementTypeT, model: type[ModelT], *args, **kwargs
) -> StatementTypeT:
statement = statement.where(
m.Book.files.any(m.FileMetadata.hash.in_(self.file_hashes))
)
return super().append_to_statement(statement, model, *args, **kwargs)
@dataclass
class CustomOrderBy(StatementFilter):
"""Order by filter with support for 'random' and 'last accessed' orderings."""
+11 -7
View File
@@ -1,16 +1,21 @@
from __future__ import annotations
import secrets
from chitai.database.models.kosync_device import KosyncDevice
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService, ModelDictT, schema_dump
from advanced_alchemy.service import (
SQLAlchemyAsyncRepositoryService,
ModelDictT,
schema_dump,
)
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
class KosyncDeviceService(SQLAlchemyAsyncRepositoryService[KosyncDevice]):
"""Service for managing KOReader devices."""
API_KEY_LENGTH_IN_BYTES = 8
class Repo(SQLAlchemyAsyncRepository[KosyncDevice]):
""" Repository for KosyncDevice entities."""
"""Repository for KosyncDevice entities."""
model_type = KosyncDevice
@@ -18,18 +23,17 @@ class KosyncDeviceService(SQLAlchemyAsyncRepositoryService[KosyncDevice]):
async def create(self, data: ModelDictT[KosyncDevice], **kwargs) -> KosyncDevice:
data = schema_dump(data)
data['api_key'] = self._generate_api_key()
data["api_key"] = self._generate_api_key()
return await super().create(data, **kwargs)
async def get_by_api_key(self, api_key: str) -> KosyncDevice:
return await self.get_one(KosyncDevice.api_key == api_key)
async def regenerate_api_key(self, device_id: int) -> KosyncDevice:
async def regenerate_api_key(self, device_id: int) -> KosyncDevice:
device = await self.get(device_id)
api_key = self._generate_api_key()
device.api_key = api_key
return await self.update(device)
def _generate_api_key(self) -> str:
return secrets.token_hex(self.API_KEY_LENGTH_IN_BYTES)
@@ -16,7 +16,9 @@ class KosyncProgressService(SQLAlchemyAsyncRepositoryService[KosyncProgress]):
repository_type = Repo
async def get_by_document_hash(self, user_id: int, document: str) -> KosyncProgress | None:
async def get_by_document_hash(
self, user_id: int, document: str
) -> KosyncProgress | None:
"""Get progress for a specific document and user."""
return await self.get_one_or_none(
KosyncProgress.user_id == user_id,
+3 -7
View File
@@ -5,7 +5,6 @@ from pathlib import Path
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
from advanced_alchemy import service
from advanced_alchemy.utils.text import slugify
# Local imports
from chitai.database.models.library import Library
@@ -18,6 +17,7 @@ from chitai.services.utils import (
from chitai.config import settings
class LibraryService(SQLAlchemyAsyncRepositoryService[Library]):
"""Service for managing libraries and their configuration."""
@@ -48,7 +48,7 @@ class LibraryService(SQLAlchemyAsyncRepositoryService[Library]):
"""
# TODO: What if a library root_path is a child of an existing library?
if existing := await self.list(Library.root_path == library.root_path):
if await self.list(Library.root_path == library.root_path):
raise ValueError(f"Library already exists at '{library.root_path}'")
if library.read_only:
@@ -57,8 +57,7 @@ class LibraryService(SQLAlchemyAsyncRepositoryService[Library]):
raise DirectoryDoesNotExist(
f"Root directory '{library.root_path}' must exist for a read-only library"
)
# TODO: Verify the read-only library has read permissions
created_library = await super().create(
service.schema_dump(library, exclude_unset=False), **kwargs
@@ -71,9 +70,6 @@ class LibraryService(SQLAlchemyAsyncRepositoryService[Library]):
await create_directory(Path(settings.consume_path) / Path(library.slug))
return created_library
# TODO: Implement library deletion and optional file deletion
async def delete(
@@ -103,8 +103,18 @@ def parse_identifier(value: str, scheme: str | None = None) -> tuple[str, str] |
# Numbered editions, in the forms covers and catalogue records actually use:
# "3rd Edition", "2E", "4e", "8_e", "/6e", "(2nd edition)", "Third International Edition".
_ORDINAL_WORDS = {
"first": 1, "second": 2, "third": 3, "fourth": 4, "fifth": 5, "sixth": 6,
"seventh": 7, "eighth": 8, "ninth": 9, "tenth": 10, "eleventh": 11, "twelfth": 12,
"first": 1,
"second": 2,
"third": 3,
"fourth": 4,
"fifth": 5,
"sixth": 6,
"seventh": 7,
"eighth": 8,
"ninth": 9,
"tenth": 10,
"eleventh": 11,
"twelfth": 12,
}
# Words that sit between the number and "Edition" and belong to the same statement.
@@ -161,7 +171,9 @@ def split_edition(title: str | None) -> tuple[str | None, int | None]:
stripped = _EDITION.sub(" ", title)
stripped = re.sub(r"\s{2,}", " ", stripped)
stripped = re.sub(r"\s+([,;:.!?])", r"\1", stripped) # "Works : What" → "Works: What"
stripped = re.sub(
r"\s+([,;:.!?])", r"\1", stripped
) # "Works : What" → "Works: What"
stripped = stripped.strip(" ,;:-–—/")
# A title that is only an edition statement is not improved by having none.
@@ -178,7 +190,9 @@ class FileExtractor(Protocol):
) -> dict[str, Any]: ...
@classmethod
async def extract_text(cls, input: UploadFile | BinaryIO | bytes | Path | str) -> str: ...
async def extract_text(
cls, input: UploadFile | BinaryIO | bytes | Path | str
) -> str: ...
class Extractor:
@@ -187,7 +201,9 @@ class Extractor:
format_priorities = {"epub": 1, "pdf": 2}
@classmethod
async def extract_metadata(cls, files: list[UploadFile] | list[Path], root_path: Path | None = None) -> dict[str, Any]:
async def extract_metadata(
cls, files: list[UploadFile] | list[Path], root_path: Path | None = None
) -> dict[str, Any]:
metadata = {}
# Sort based on file priority
@@ -235,7 +251,7 @@ class Extractor:
metadata = FilepathExtractor.extract_metadata(files[0], root_path) | metadata
# format the title
if metadata.get('title', None):
if metadata.get("title", None):
# Before the subtitle split, so the edition cannot be mistaken for one:
# "How Linux Works, 3rd Edition: What Every Superuser Should Know" has to
# lose the edition first for the colon count to mean anything.
@@ -282,8 +298,8 @@ class Extractor:
file_ext = get_file_extension(filename)
if file_ext is None:
return float('inf')
return float("inf")
return Extractor.format_priorities.get(file_ext, float("inf"))
@classmethod
@@ -618,8 +634,10 @@ class FilepathExtractor(FileExtractor):
"""Extracts metadata from the filepath."""
@classmethod
def extract_metadata(cls, input: UploadFile | Path | str, root_path: Path | None = None) -> dict[str, Any]:
def extract_metadata(
cls, input: UploadFile | Path | str, root_path: Path | None = None
) -> dict[str, Any]:
if isinstance(input, UploadFile):
path = Path(input.filename).parent
else:
@@ -627,33 +645,34 @@ class FilepathExtractor(FileExtractor):
if root_path:
path = path.relative_to(root_path)
parts = path.parts
metadata: dict[str, str | None] = {}
if len(parts) == 3:
# Format: Author/Series/Part - Title/filename
metadata['author'] = parts[0]
metadata["author"] = parts[0]
# Extract part number and title from directory name (parts[2])
dirname = parts[2]
match = re.match(r'^([\d.]+)\s*-\s*(.+)$', dirname)
match = re.match(r"^([\d.]+)\s*-\s*(.+)$", dirname)
if match:
metadata['series_position'] = match.group(1) # Keep as string
metadata['series'] = parts[1]
metadata['title'] = match.group(2).strip()
metadata["series_position"] = match.group(1) # Keep as string
metadata["series"] = parts[1]
metadata["title"] = match.group(2).strip()
else:
metadata['series'] = parts[1]
metadata['title'] = path.stem
metadata["series"] = parts[1]
metadata["title"] = path.stem
elif len(parts) == 2:
# Format: Author/Title
metadata['author'] = parts[0]
metadata['title'] = path.stem # Remove extension
metadata["author"] = parts[0]
metadata["title"] = path.stem # Remove extension
return metadata
class FilenameExtractor(FileExtractor):
"""Extracts metadata from the filename."""
+37 -23
View File
@@ -4,43 +4,57 @@ from typing import Any, Literal, Optional, Sequence
from pydantic import BaseModel, Field
from datetime import datetime
class LinkTypes(StrEnum):
NAVIGATION = "application/atom+xml;profile=opds-catalog;kind=navigation"
ACQUISITION = "application/atom+xml;profile=opds-catalog;kind=acquisition"
OPEN_SEARCH = "application/opensearchdescription+xml"
class AcquisitionRelations(StrEnum):
_BASE = "http://opds-spec.org/acquisition"
ACQUISITION = _BASE # A generic relation that indicates that the entry may be retrieved
OPEN_ACCESS = f"{_BASE}/open-access" # Entry may be retrieved without any requirement
BORROW = f"{_BASE}/borrow" # Entry may be retrieved as part of a lending transaction
BUY = f"{_BASE}/buy" # Entry may be retrieved as part of a purchase
SAMPLE = f"{_BASE}/sample" # Subset of the entry may be retrieved
PREVIEW = f"{_BASE}/preview" # Subset of the entry may be retrieved
SUBSCRIBE = f"{_BASE}/subscribe" # Entry my be retrieved as a part of a subscription
ACQUISITION = (
_BASE # A generic relation that indicates that the entry may be retrieved
)
OPEN_ACCESS = (
f"{_BASE}/open-access" # Entry may be retrieved without any requirement
)
BORROW = (
f"{_BASE}/borrow" # Entry may be retrieved as part of a lending transaction
)
BUY = f"{_BASE}/buy" # Entry may be retrieved as part of a purchase
SAMPLE = f"{_BASE}/sample" # Subset of the entry may be retrieved
PREVIEW = f"{_BASE}/preview" # Subset of the entry may be retrieved
SUBSCRIBE = (
f"{_BASE}/subscribe" # Entry my be retrieved as a part of a subscription
)
class NavigationRelations(StrEnum):
_BASE = ""
class LinkRelations(StrEnum):
""" Link types for OPDSv1.2 related resources
"""Link types for OPDSv1.2 related resources
https://specs.opds.io/opds-1.2.html#6-additional-link-relations
"""
_BASE = "http://opds-spec.org"
START = "start" # The OPDS catalog root
SUBSECTION = "subsection" # an OPDS feed not better described by any of the below relations
SHELF = f"{_BASE}/shelf" # Entries acquired by the euser
SUBSCRIPTIONS = f"{_BASE}/subscriptions" # Entries available with users's subscription
NEW = f"{_BASE}/sort/new" # Newest entries
POPULAR = f"{_BASE}/sort/popular" # Most popular entries
FEATURED = f"{_BASE}/featured" # Entries selected for promotion
RECOMMENDED = f"{_BASE}/recommended" # Entries recommended to the specific user
START = "start" # The OPDS catalog root
SUBSECTION = (
"subsection" # an OPDS feed not better described by any of the below relations
)
SHELF = f"{_BASE}/shelf" # Entries acquired by the euser
SUBSCRIPTIONS = (
f"{_BASE}/subscriptions" # Entries available with users's subscription
)
NEW = f"{_BASE}/sort/new" # Newest entries
POPULAR = f"{_BASE}/sort/popular" # Most popular entries
FEATURED = f"{_BASE}/featured" # Entries selected for promotion
RECOMMENDED = f"{_BASE}/recommended" # Entries recommended to the specific user
class Feed(BaseModel): # OPDS Catalog root element
@@ -117,9 +131,8 @@ class AcquisitionFeedLink(Link):
class NavigationFeedLink(Link):
type: str = Field(
default=LinkTypes.NAVIGATION, serialization_alias="@type"
)
type: str = Field(default=LinkTypes.NAVIGATION, serialization_alias="@type")
class Content(BaseModel):
type: Literal["text"] = Field(default="text", serialization_alias="@type")
@@ -170,9 +183,10 @@ class Entry(BaseModel):
data = super().model_dump(**kwargs)
return {"entry": data}
@dataclass
class PaginationResult:
next_link: Optional[Link]
prev_link: Optional[Link]
current_offset: int
total_count: int
total_count: int
+57 -70
View File
@@ -1,4 +1,3 @@
from typing import Any, Callable, Sequence
from urllib.parse import quote_plus, urlencode
from litestar import Request
@@ -16,9 +15,10 @@ from .models import (
AcquisitionFeedLink,
NavigationFeed,
NavigationFeedLink,
PaginationResult
PaginationResult,
)
def get_opensearch_document(base_url: str = "/opds/search?") -> str:
search = {
"OpenSearchDescription": {
@@ -120,19 +120,20 @@ def create_navigation_feed(
pretty=True,
)
def create_library_navigation_feed(library: m.Library) -> str:
entries = [
Entry(
id=f"/opds/library/{library.id}/all-books",
title='All Books',
title="All Books",
link=[
AcquisitionFeedLink(
rel="subsection",
href=f"/opds/acquisition?libraries={library.id}&paginated=1&pageSize=50&feed_title=AllBooks&feed_id=/opds/library/{library.id}/all-books",
title="All Books",
)
]
],
),
Entry(
id=f"/opds/library/{library.id}/recently-added",
@@ -141,9 +142,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink(
rel="http://opds-spec.org/sort/new",
href=f"/opds/acquisition?libraries={library.id}&orderBy=created_at&pageSize=50&feed_title=RecentlyAdded&feed_id=/opds/library/{library.id}/recently-added",
title="Recently Added"
title="Recently Added",
)
]
],
),
Entry(
id=f"/opds/library/{library.id}/shelves",
@@ -152,9 +153,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink(
rel="subsection",
href=f"/opds/library/{library.id}/shelves?paginated=1&pageSize=10",
title="Bookshelves"
title="Bookshelves",
)
]
],
),
Entry(
id=f"/opds/library/{library.id}/tags",
@@ -163,9 +164,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink(
rel="subsection",
href=f"/opds/library/{library.id}/tags?paginated=1&pageSize=10",
title="Tags"
title="Tags",
)
]
],
),
Entry(
id=f"/opds/library/{library.id}/authors",
@@ -174,9 +175,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink(
rel="subsection",
href=f"/opds/library/{library.id}/authors?paginated=1&pageSize=10",
title="Authors"
title="Authors",
)
]
],
),
Entry(
id=f"/opds/library/{library.id}/publishers",
@@ -185,34 +186,34 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink(
rel="subsection",
href=f"/opds/library/{library.id}/publishers?paginated=1&pageSize=10",
title="Publishers"
title="Publishers",
)
]
],
),
]
feed = create_navigation_feed(
id=f'/library/{library.id}',
id=f"/library/{library.id}",
title=library.name,
self_url=f'/opds/library/{library.id}',
links=[
],
entries=entries
self_url=f"/opds/library/{library.id}",
links=[],
entries=entries,
)
return feed
def create_collection_navigation_feed(
library: m.Library,
collection_type: str,
items: Sequence[m.BookList | m.Tag | m.Author | m.Publisher | m.BookSeries],
links: list[Link] = list(),
# Title is usually derived from the model's name or title
get_title: Callable[[Any], str] = lambda x: getattr(x, 'title', getattr(x, 'name', str(x)))
get_title: Callable[[Any], str] = lambda x: getattr(
x, "title", getattr(x, "name", str(x))
),
) -> str:
entries = [
Entry(
id=f"/opds/library/{library.id}/{collection_type}/{item.id}",
@@ -222,53 +223,43 @@ def create_collection_navigation_feed(
href=f"/opds/acquisition?{collection_type}={item.id}&pageSize=50&paginated=1&feed_title={quote_plus(get_title(item))}&feed_id=/opds/library/{library.id}/{collection_type}/{item.id}&search=True",
title=get_title(item),
)
]
) for item in items
],
)
for item in items
]
return create_navigation_feed(
id=f"/opds/library/{library.id}/{collection_type}",
title=collection_type.title(),
self_url=f'/opds/library/{library.id}/{collection_type}',
self_url=f"/opds/library/{library.id}/{collection_type}",
entries=entries,
links=links
links=links,
)
def create_next_paginated_link(
request: Request,
total: int,
current_count: int,
offset: int,
feed_title: str
) -> Link | None:
if total <= current_count + offset:
return None
params = dict(request.query_params)
params['currentPage'] = params.get('currentPage', 1) + 1
next_url = f"{request.url.path}?{urlencode(list(params.items()), doseq=True)}"
return Link(
rel="next",
href=next_url,
title=feed_title,
type=LinkTypes.NAVIGATION
)
request: Request, total: int, current_count: int, offset: int, feed_title: str
) -> Link | None:
if total <= current_count + offset:
return None
params = dict(request.query_params)
params["currentPage"] = params.get("currentPage", 1) + 1
next_url = f"{request.url.path}?{urlencode(list(params.items()), doseq=True)}"
return Link(rel="next", href=next_url, title=feed_title, type=LinkTypes.NAVIGATION)
def create_search_link(
request: Request,
exclude_params: set[str] | None = None
request: Request, exclude_params: set[str] | None = None
) -> Link:
"""Create search link with current filters applied"""
if exclude_params is None:
exclude_params = {'currentPage', 'feed_title', 'feed_id', 'search', 'paginated'}
params = {
k: v for k, v in request.query_params.items()
if k not in exclude_params
}
exclude_params = {"currentPage", "feed_title", "feed_id", "search", "paginated"}
params = {k: v for k, v in request.query_params.items() if k not in exclude_params}
return Link(
rel="search",
href=f"/opds/opensearch?{urlencode(list(params.items()), doseq=True)}",
@@ -277,7 +268,6 @@ def create_search_link(
)
def create_pagination_links(
request: Request,
total: int,
@@ -287,38 +277,35 @@ def create_pagination_links(
link_type: str = LinkTypes.ACQUISITION,
) -> PaginationResult:
"""Create next/prev pagination links using limit/offset"""
next_link = None
prev_link = None
# Create next link if there are more items
if offset + limit < total:
params = dict(request.query_params)
# Calculate next page number
current_page = (offset // limit) + 1
params['currentPage'] = current_page + 1
params["currentPage"] = current_page + 1
next_url = f"{request.url.path}?{urlencode(params, doseq=True)}"
next_link = Link(
rel="next",
href=next_url,
title=f"{feed_title} - Next",
type=link_type
rel="next", href=next_url, title=f"{feed_title} - Next", type=link_type
)
# Create previous link if not on first page
if offset > 0:
params = dict(request.query_params)
# Calculate previous page number
current_page = (offset // limit) + 1
params['currentPage'] = max(1, current_page - 1)
params["currentPage"] = max(1, current_page - 1)
prev_url = f"{request.url.path}?{urlencode(params, doseq=True)}"
prev_link = Link(
rel="previous",
href=prev_url,
title=f"{feed_title} - Previous",
type=link_type
type=link_type,
)
return PaginationResult(next_link, prev_link, offset, total)
+4 -2
View File
@@ -213,6 +213,7 @@ async def create_directory(dir_path: Path | str) -> None:
await aios.makedirs(dir_path, exist_ok=True)
async def move_file(src_path: Path, dest_path: Path, create_dirs=True) -> None:
"""
Move a file from source to destination asynchronously.
@@ -239,6 +240,7 @@ async def move_file(src_path: Path, dest_path: Path, create_dirs=True) -> None:
# all configured separately, so they can easily be separate mounts.
shutil.move(str(src_path), str(dest_path))
async def copy_file(src_path: Path, dest_path: Path, create_dirs: bool = True) -> None:
"""
Copy a file, streaming it rather than reading it whole.
@@ -451,7 +453,7 @@ def get_file_extension(file: Path | str | UploadFile) -> str | None:
elif isinstance(file, UploadFile):
return Path(file.filename).suffix.lower()[1:]
raise ValueError("file object type is not supported")
@@ -476,7 +478,7 @@ def get_filename(file: Path | str, ext: bool = True) -> str:
filename = Path(file.name)
else:
raise ValueError("file object type is not supported")
if ext:
return str(filename)
+9
View File
@@ -24,6 +24,10 @@ services:
backend:
image: git.jaroszew.ski/patrick/chitai-backend:${CHITAI_VERSION:-latest}
# Only used when the image above is not present locally. Run `docker compose build` to
# build from source instead of pulling a release.
build: ./backend
networks:
@@ -53,6 +57,8 @@ services:
condition: service_healthy
frontend:
image: git.jaroszew.ski/patrick/chitai-frontend:${CHITAI_VERSION:-latest}
build: ./frontend
networks:
@@ -66,6 +72,9 @@ services:
environment:
VITE_BACKEND_API_URL: ${CHITAI_API_URL}
# Must match the URL the browser uses, or SvelteKit rejects form POSTs as cross-origin.
ORIGIN: ${CHITAI_ORIGIN:-http://localhost:3000}
depends_on:
backend:
condition: service_healthy
+277
View File
@@ -0,0 +1,277 @@
# Building release images in CI
Design for publishing `chitai-backend` and `chitai-frontend` container images from a tagged
release, on Gitea Actions, to the Gitea package registry, for `linux/amd64`.
## What exists today
- `origin` is Gitea 1.27 at `git.jaroszew.ski`. The repo is **public**, with `has_actions` and
`has_packages` both true. There is no `.gitea/` or `.github/` directory and no CI of any kind.
- There are **no tags** in the repository. `backend/pyproject.toml` says `0.1.0`,
`frontend/package.json` says `0.0.1`; nothing reads either.
- `backend/Dockerfile` and `frontend/Dockerfile` are both multi-stage and both already build a
runnable image. `docker-compose.yml` builds them from source with `build: ./backend` and
`build: ./frontend` — there is no `image:` key, so there is nothing for a user to pull.
So the work is not "make the images build" — they build. It is "make an image built on one machine
correct on another", then automate producing one per tag.
## The blocker: the frontend image hardcodes the backend URL
This has to be fixed before publishing an image is meaningful.
`frontend/src/lib/server/config.ts` reads the backend URL through `import.meta.env`:
```ts
export const BACKEND_API_URL = import.meta.env.VITE_BACKEND_API_URL || 'http://localhost:8000';
```
Vite replaces `import.meta.env.VITE_*` **at build time**, including in the SSR bundle. The current
build output in `frontend/build/` shows exactly what that produces:
```js
// frontend/build/server/chunks/config-BvKh7uym.js
const BACKEND_API_URL = "http://localhost:8000";
```
`frontend/Dockerfile` sets no `VITE_BACKEND_API_URL` before `pnpm run build`, so the string baked
into any image built from it is `http://localhost:8000`. The `VITE_BACKEND_API_URL: ${CHITAI_API_URL}`
entry under the compose `frontend` service is therefore **dead** — it sets a process environment
variable that nothing reads, in a process whose value was decided at build time. Inside the
container `localhost:8000` is the frontend's own port, not the backend.
Two ways out, and only one of them is right for a published image:
- **Build arg.** `ARG VITE_BACKEND_API_URL` in the build stage. This works, but it makes the image
specific to one deployment's topology — CI would bake `http://backend:8000` and anyone whose
service is named differently gets an image that cannot be repointed. Wrong for a release artifact.
- **Runtime env, recommended.** Read it through SvelteKit's dynamic env, which is `process.env` at
request time:
```ts
import { env } from '$env/dynamic/private';
export const BACKEND_API_URL = env.VITE_BACKEND_API_URL || 'http://localhost:8000';
```
`$lib/server/config.ts` is server-only and its one consumer (`$lib/server/api.ts`) is too, so
`$env/dynamic/private` is available everywhere it is used. The compose entry then starts working
as written, and the same image serves any deployment.
Worth renaming the variable to `CHITAI_API_URL` at the same time — the `VITE_` prefix now means
the opposite of what it does — but that is a follow-up, not a prerequisite. If you do rename it,
the fallback in `.env.prod-example` and the compose `environment:` block move with it.
Same class of problem, same fix window: `frontend/Dockerfile` sets `ENV ORIGIN=http://localhost:3000`.
That one *is* read at runtime by `adapter-node`, so it can be overridden — but nothing overrode it,
and adapter-node rejects cross-origin form POSTs when `ORIGIN` does not match the browser's, so
every deployment behind a real domain 403s on its first login. Now set as
`ORIGIN: ${CHITAI_ORIGIN:-http://localhost:3000}` on the compose `frontend` service, with a
documented `CHITAI_ORIGIN` in `.env.prod-example`.
**Both are done.** The built module now reads `private_env.VITE_BACKEND_API_URL`, and the published
image was verified by running it with `VITE_BACKEND_API_URL` pointed at a throwaway listener: the
container's `GET /access/me` arrived there rather than at `localhost:8000`.
## Toolchain pinning
Two reproducibility gaps that a release pipeline turns from cosmetic into real, because CI builds
from a clean container every time and your laptop does not.
- **pnpm has no pin.** `frontend/package.json` has no `packageManager` field, so `corepack enable`
followed by `pnpm install` resolves to whatever version corepack considers current on the day the
build runs. `pnpm-lock.yaml` is `lockfileVersion: 9.0`, i.e. pnpm 9/10; a future pnpm 11 could
refuse it, and `--frozen-lockfile` would fail a release for reasons unrelated to the release. Add
`"packageManager": "pnpm@<version from your nix shell>"` and let corepack honour it.
- **`pnpm-workspace.yaml` is never copied into the image.** It carries `onlyBuiltDependencies`
(`esbuild`, `@tailwindcss/oxide`), and pnpm 10 blocks postinstall scripts that are not listed
there. Both install stages in `frontend/Dockerfile` copy only `pnpm-lock.yaml` and `package.json`,
so the container install runs under different rules than the local one. Copy it alongside
`package.json` in the `prod-deps` and `build` stages so the two agree.
Both are one-line changes and both belong before the first tag, not after. **Both are done** —
`packageManager` is pinned to the dev shell's `pnpm@11.20.0`, and `pnpm-workspace.yaml` is copied
into the `prod-deps` and `build` stages. `docker compose build` was re-run against the change.
## Prerequisites on the Gitea instance
Verify these before writing the workflow; each one fails the job in a way that looks like a bug in
the workflow.
1. **A registered `act_runner`.** Gitea Actions is enabled instance-side but does nothing without a
runner. Register one against the repo or the instance with the label `ubuntu-latest`.
2. **The runner needs a Docker daemon.** This is the single most common failure for image-building
workflows on Gitea. `act_runner` in docker mode runs each job inside a container that has no
daemon of its own. Either run a `docker:dind` sidecar next to the runner and set
`DOCKER_HOST=tcp://docker:2376` (with TLS certs shared over a volume), or run the runner in host
mode with the socket mounted. The dind sidecar is the safer of the two — mounting the host socket
into job containers gives any workflow root on the runner host.
3. **Action resolution.** A bare `uses: docker/build-push-action@v6` does not mean github.com here.
Gitea resolves it against `[actions] DEFAULT_ACTIONS_URL`, which defaults to `https://gitea.com`.
That is fine as it stands — `actions/checkout@v4`, `docker/setup-buildx-action@v3`,
`docker/login-action@v3`, `docker/metadata-action@v5` and `docker/build-push-action@v6` are all
mirrored on gitea.com at those tags (verified 2026-08-17). Worth knowing because it is where an
action reference resolves from if that setting is ever changed; `DEFAULT_ACTIONS_URL = github`
in `app.ini` is the fix if so. Writing full `https://` URLs in `uses:` also works on Gitea but
is invalid syntax on GitHub Actions, so it would cost portability for no gain.
4. **A registry credential.** Gitea auto-injects `secrets.GITEA_TOKEN`, but whether it carries
package-write scope has varied across versions. Try it first; if the push 401s, create a personal
access token with `write:package` and store it as the repo secret `REGISTRY_TOKEN`. The workflow
below reads `REGISTRY_TOKEN` with a fallback to the automatic token.
5. **Package visibility.** Gitea ties package visibility to the owner rather than offering a
per-package toggle. The repo is public, so anonymous pulls should work — confirm with a
`docker pull` from a logged-out machine after the first release, because the README will tell
people to do exactly that.
## The workflow
Written as **`.gitea/workflows/release.yml`**, triggered by tags matching `v*`. Notes on the choices
made there:
- **`github.*` context, not `gitea.*`.** Both exist on Gitea; the third-party actions read the
`GITHUB_*` environment anyway, and using it keeps the file portable if the repo is ever mirrored.
`github.repository` is `patrick/chitai`, so the images are
`git.jaroszew.ski/patrick/chitai-backend` and `…/chitai-frontend`.
- **Tags produced from `v1.2.3`:** `1.2.3`, `1.2`, `1`, and `latest`. `metadata-action`'s default
`latest=auto` flavour adds `latest` only for a non-prerelease semver, so `v0.2.0-rc.1` publishes
`0.2.0-rc.1` and leaves `latest` where it was. That is what makes release-candidate tags a safe
way to exercise the pipeline.
- **`fail-fast: false`** so a frontend failure does not cancel a backend build that was going to
succeed. The two images are independent artifacts; a half-published release is easier to reason
about than a cancelled one.
- **`type=gha` cache** relies on `act_runner`'s built-in cache server exporting
`ACTIONS_CACHE_URL` / `ACTIONS_RUNTIME_TOKEN`. If your runner has caching disabled, buildx warns
and continues, or errors depending on version — just delete the two `cache-*` lines. The
Dockerfiles' `--mount=type=cache` blocks do nothing across ephemeral runners either way, and a
cold build of both images is a few minutes.
## Gating: formatting, linting, types and tests
`ci.yml` runs these on every push and pull request, and `release.yml`'s `quality` job runs the
blocking half again on the tagged commit, so a release cannot publish an image whose tests fail.
My first draft of this document argued for keeping all of it out of the release path, on the
grounds that everything was red. Measuring rather than trusting `TODO.md` showed that was mostly
wrong:
| Check | Before | After the sweep | Gate |
| --- | --- | --- | --- |
| `pytest tests/` | **411 passed** in 3 min | unchanged | blocking |
| `ruff format --check src/` | 27 of 66 files | clean | blocking |
| `ruff check src/` | 110 errors | clean | blocking |
| `prettier --check .` | 48 files | clean | blocking |
| `eslint .` | 1804 → **87 real** | 87 | non-blocking |
| `pnpm check` | 30 errors | 30 | non-blocking |
The test suite was green the whole time, so gating on it costs nothing. `ruff check`'s 110 were
104 unused imports, and **all 64 that survived `--fix` were in `__init__.py`** — deliberate
re-exports, so the fix is `per-file-ignores` in `pyproject.toml`, not deleting them. Of eslint's
1804, **1717 were the vendored pdf.js under `static/`**, which the config never ignored the way it
ignores `src/lib/vendor/`; adding it leaves 87 real ones.
Two things the sweep turned up that are worth remembering:
- **ruff's suggested fix for `E712` would have broken the query.** `services/filters/book.py` had
`m.BookProgress.completed == True`, and ruff proposes `if m.BookProgress.completed:` — Python
truthiness on a SQLAlchemy Column, which does not generate SQL at all. The correct idiom is
`.is_(True)`, which the adjacent line already used. Never run `ruff check --fix --unsafe-fixes`
over query-building code without reading every hunk.
- **Prettier reformatted the generated `schema.d.ts`**, which was 3109 of the sweep's 3946 changed
lines. `openapi-typescript` writes its own style, so that file would have churned by thousands of
lines on every regeneration — and then failed the very gate being added. It is in
`.prettierignore` now.
`eslint` and `pnpm check` run with `continue-on-error: true`. That is an honest weak gate: it
reports without failing, so the counts stay visible and cannot grow silently unnoticed, but nobody
is blocked by 117 pre-existing problems they did not create. Drop the line from each step as its
count reaches zero.
The other thing worth gating on is that the images actually start, which is the failure mode a release
introduces and which nothing else catches. That is the `smoke` job in the same workflow: it copies
`.env.prod-example`, pins `CHITAI_VERSION` to the tag, `docker compose pull`s the images that were
just pushed and brings the stack up with `--wait`, then curls both healthchecks.
This is cheap and it covers the three things that break a release image: migrations failing to apply
from `entrypoint.sh`, the frontend being unable to reach the backend (the baked-URL bug above —
`--wait` fails because the frontend never goes healthy), and a missing runtime env var. It tests the
artifact that was pushed, not a rebuild of it.
`--wait` depends on healthchecks. `db` declares one inline in `docker-compose.yml`, and the backend
image carries a `HEALTHCHECK` in its Dockerfile which compose inherits. The frontend had neither, so
one was added to `frontend/Dockerfile` — in the image rather than in compose, matching the backend
and covering anyone running the image without compose. It probes `/login` with **node's global
`fetch`, not curl**, because `node:24-slim` ships no curl and adding one for a healthcheck is a
package and a CVE surface for nothing. It reads `PORT` so it keeps working if the port is
overridden.
Note the smoke job runs `cp .env.prod-example .env`, which is destructive on a developer machine —
it is safe only because a CI checkout has no `.env`. Don't run those lines locally.
## Making the images consumable
The images are pointless if `docker-compose.yml` still builds from source. Both services now carry
`image:` **and** keep `build:` — compose pulls when the image is absent, and `docker compose build`
still builds from source and tags the result under the same name:
```yaml
backend:
image: git.jaroszew.ski/patrick/chitai-backend:${CHITAI_VERSION:-latest}
build: ./backend
```
`CHITAI_VERSION` and `CHITAI_ORIGIN` are documented in `.env.prod-example`, and the README's install
steps now copy and edit `.env` *before* pulling — the pull cannot resolve `${CHITAI_VERSION}` until
that file exists, so the old ordering would have silently fetched `latest`.
## Versioning
Adopt `vMAJOR.MINOR.PATCH`. The tag is the source of truth; `metadata-action` derives everything
from it and the OCI labels record it. There is no tag today, so the first release is a decision —
`v0.1.0` matches `backend/pyproject.toml` and is the honest number for an app with an open
"any authenticated user can delete any library" item.
Do not automate bumping `pyproject.toml` and `package.json` from the tag. It requires CI to commit
back to the repo, and neither version is read by anything. Either keep a two-line manual checklist
(bump both, commit, tag) or delete `frontend/package.json`'s version field and treat the backend's
as the product version. Bumping by hand is the smaller cost.
## Order of work
- [x] **Runtime config.** `frontend/src/lib/server/config.ts` → `$env/dynamic/private`; `ORIGIN` on
the compose frontend service; `CHITAI_ORIGIN` in `.env.prod-example`.
- [x] **Toolchain pins.** `packageManager` in `frontend/package.json`; `pnpm-workspace.yaml` copied
in both `frontend/Dockerfile` install stages.
- [x] **The workflow.** `.gitea/workflows/release.yml`, both jobs.
- [x] **Consumable images.** `image:` keys in `docker-compose.yml`, frontend `HEALTHCHECK`,
`CHITAI_VERSION`, README install steps.
- [ ] **Stand up `act_runner` with a working Docker daemon.** Not something the repo can carry.
Prove it with a throwaway workflow running `docker version` before trusting the release one.
- [ ] **Tag `v0.1.0-rc.1`.** Confirm two images land in the registry, that `latest` was *not* moved,
and that the smoke job goes green.
- [ ] **Confirm an anonymous `docker pull`** from a logged-out machine, since the README tells people
to do exactly that.
- [ ] **Tag `v0.1.0`.**
Everything the repository can hold is in place; what remains is instance-side and needs a real tag
to exercise. Verified locally along the way: `docker compose build` succeeds against both changed
Dockerfiles, `docker compose config` resolves the image names, and the built frontend image
honours `VITE_BACKEND_API_URL` at runtime and reports `healthy` to Docker.
## Deferred, deliberately
- **Multi-arch.** amd64 only for now. Adding `linux/arm64` under QEMU roughly triples the job and the
Python and pnpm installs are exactly the workload emulation is worst at; a native arm runner and a
fan-out/merge workflow is the answer if it is ever needed.
- **Building on `main`.** An `edge` tag from every push to `main` is a two-line addition to the same
workflow (`on: push: branches: [main]` plus `type=raw,value=edge,enable={{is_default_branch}}`).
Left out because it doubles registry churn for a deployment target that does not exist yet.
- **PR CI.** Formatting, lint, type and test gates are a separate workflow on a separate trigger and
should not be mixed into the release path — see the gating section.
- **Gitea Releases.** A job creating a release object with generated notes is nice-to-have and can
be added once the tags mean something.
- **Signing / SBOM / provenance.** `build-push-action` can emit provenance and an SBOM, and cosign
can sign the digests. Worth it if the images are ever consumed by anyone other than you; not worth
the key management before then.
- **`backend/.dockerignore` is thin.** It excludes `.git`, `__pycache__` and the ruff cache, but not
`.venv/`, `.postgres/`, `libraries/`, `covers/` or `tests/`. None of those reach the image — the
Dockerfile copies specific paths — and none exist in a CI checkout since they are gitignored, so
this does not affect the pipeline. It does make local builds slower than they need to be.
+5
View File
@@ -8,9 +8,14 @@ bun.lockb
# Ignore artifacts:
build
coverage
.pytest_cache
# Miscellaneous
/static/
# Vendored third-party source, copied verbatim by scripts/vendor-foliate.sh
/src/lib/vendor/
# Generated by openapi-typescript, which has its own formatting. Reformatting it here would
# make every regeneration a several-thousand-line diff.
/src/lib/schema/openapi/schema.d.ts
+6 -3
View File
@@ -163,9 +163,12 @@ Observed in the current tree — don't mistake these for intentional patterns to
current; regenerate it again after any backend API change, with
`pnpm exec openapi-typescript http://localhost:8000/schema/openapi.json -o src/lib/schema/openapi/schema.d.ts`
against a backend running **your** branch — a stale server silently writes a stale file.
- `pnpm lint` does not pass either — 131 pre-existing ESLint errors, 35 of them
`svelte/no-navigation-without-resolve` on plain `href`s, plus ~59 files Prettier would rewrite
(mostly vendored shadcn components). Check the files you touched, not the whole tree.
- **Prettier is clean and CI blocks on it** — run `pnpm format` before finishing. Two things it
must not touch are in `.prettierignore`: the vendored foliate-js, and
`src/lib/schema/openapi/schema.d.ts`, which `openapi-typescript` regenerates in its own style.
- `pnpm exec eslint .` reports **87 pre-existing errors as of 2026-08-17**, all in `src/`. CI runs
it non-blocking (`continue-on-error`) until that reaches zero. `static/pdfjs/` is ignored
alongside `src/lib/vendor/` — it is vendored too, and linting it produced 1717 further errors.
- `src/routes/api/[...path]/+server.ts` — all four handlers are annotated `RequestHandler` while the
import of that type is commented out at line 4. It also buffers whole **responses** with
`arrayBuffer()` and forwards no `Range` header, so book downloads are not streamed. **Requests**
+7 -2
View File
@@ -11,7 +11,9 @@ WORKDIR /app
FROM base AS prod-deps
COPY pnpm-lock.yaml ./
# pnpm-workspace.yaml carries onlyBuiltDependencies; without it pnpm blocks the postinstall
# scripts it lists, so the install here would not match a local one.
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
RUN --mount=type=cache,target=/pnpm/store \
pnpm fetch --frozen-lockfile
@@ -25,7 +27,7 @@ RUN --mount=type=cache,target=/pnpm/store \
FROM base AS build
COPY pnpm-lock.yaml package.json ./
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
RUN --mount=type=cache,target=/pnpm/store \
pnpm install --frozen-lockfile
@@ -54,4 +56,7 @@ EXPOSE 3000
WORKDIR /app
HEALTHCHECK --interval=10s --timeout=3s --retries=5 --start-period=10s \
CMD ["node", "-e", "fetch(`http://127.0.0.1:${process.env.PORT || 3000}/login`).then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
CMD [ "node", "build" ]
+10 -1
View File
@@ -13,7 +13,9 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig(
includeIgnoreFile(gitignorePath),
// Vendored third-party source. Tracked, so .gitignore does not cover it.
{ ignores: ['src/lib/vendor/**'] },
// static/pdfjs is the pdf.js viewer, vendored the same way as src/lib/vendor/foliate-js;
// linting it produced 1717 of the 1804 errors this config used to report.
{ ignores: ['src/lib/vendor/**', 'static/pdfjs/**'] },
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
@@ -35,6 +37,13 @@ export default defineConfig(
files: ['src/lib/components/ui/**'],
rules: { 'svelte/no-navigation-without-resolve': 'off' }
},
{
// no-useless-assignment joined eslint:recommended in ESLint 10, and its flow analysis
// does not model runes: it reads `let { ref = $bindable(null) } = $props()` as a value
// that is never read. Deleting the default, as it suggests, breaks the binding.
files: ['**/*.svelte'],
rules: { 'no-useless-assignment': 'off' }
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
+23 -22
View File
@@ -2,6 +2,7 @@
"name": "chitai-web",
"private": true,
"version": "0.0.1",
"packageManager": "pnpm@11.20.0",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -14,42 +15,42 @@
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@eslint/compat": "^1.4.1",
"@eslint/js": "^9.39.4",
"@iconify/svelte": "^5.2.1",
"@internationalized/date": "^3.12.0",
"@eslint/compat": "^2.1.0",
"@eslint/js": "^10.0.1",
"@iconify/svelte": "^5.2.2",
"@internationalized/date": "^3.12.3",
"@lucide/svelte": "^0.544.0",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.53.4",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.70.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.1",
"@types/node": "^22.19.15",
"bits-ui": "^2.16.3",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^26.2.0",
"bits-ui": "^2.18.1",
"clsx": "^2.1.1",
"eslint": "^9.39.4",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.15.0",
"globals": "^16.5.0",
"eslint-plugin-svelte": "^3.23.0",
"globals": "^17.11.0",
"jsrepo": "^2.5.2",
"openapi-typescript": "^7.13.0",
"prettier": "^3.8.1",
"prettier": "^3.9.6",
"prettier-plugin-svelte": "^3.5.2",
"prettier-plugin-tailwindcss": "^0.8.1",
"svelte": "^5.53.7",
"svelte-check": "^4.4.5",
"tailwind-merge": "^3.5.0",
"svelte": "^5.56.9",
"svelte-check": "^4.7.6",
"tailwind-merge": "^3.6.0",
"tailwind-scrollbar": "^4.0.2",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.2.1",
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1"
"typescript-eslint": "^8.67.0",
"vite": "^7.3.6"
},
"dependencies": {
"construct-style-sheets-polyfill": "^3.1.0",
"mode-watcher": "^1.1.0",
"svelte-sonner": "^1.0.8",
"zod": "^4.3.6"
"svelte-sonner": "^1.2.1",
"zod": "^4.4.3"
}
}
+1187 -1225
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -10,7 +10,8 @@
--radius: 0.625rem;
/* Typography — system stacks, so nothing depends on a CDN or a webfont build. */
--app-font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--app-font-sans:
system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--app-font-serif: Georgia, 'Iowan Old Style', 'Times New Roman', serif;
--app-font-mono: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace;
+34 -24
View File
@@ -1,5 +1,10 @@
import { command, getRequestEvent, query } from '$app/server';
import { bookshelfCreate, bookshelfQuerySchema, modifyBooksInShelf, type Bookshelf } from '$lib/schema/bookshelf';
import {
bookshelfCreate,
bookshelfQuerySchema,
modifyBooksInShelf,
type Bookshelf
} from '$lib/schema/bookshelf';
import { createQueryParams } from '$lib/utils';
import { error } from '@sveltejs/kit';
@@ -19,46 +24,51 @@ export const listBookshelves = query(bookshelfQuerySchema, async (data) => {
return await response.json();
});
export const addBooksToShelf = command(modifyBooksInShelf, async ({ shelf_id, ...data }): Promise<Bookshelf> => {
const { locals } = getRequestEvent();
export const addBooksToShelf = command(
modifyBooksInShelf,
async ({ shelf_id, ...data }): Promise<Bookshelf> => {
const { locals } = getRequestEvent();
const params = createQueryParams(data);
const params = createQueryParams(data);
const response = await locals.api.post(`/shelves/${shelf_id}/books?${params.toString()}`, {});
const response = await locals.api.post(`/shelves/${shelf_id}/books?${params.toString()}`, {});
if (!response.ok) {
const message = await response.text();
error(response.status, message);
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
return await response.json();
}
);
return await response.json()
});
export const removeBooksFromShelf = command(
modifyBooksInShelf,
async ({ shelf_id, ...data }): Promise<Bookshelf> => {
const { locals } = getRequestEvent();
export const removeBooksFromShelf = command(modifyBooksInShelf, async ({ shelf_id, ...data }): Promise<Bookshelf> => {
const { locals } = getRequestEvent();
const params = createQueryParams(data);
const params = createQueryParams(data);
const response = await locals.api.delete(`/shelves/${shelf_id}/books?${params.toString()}`);
const response = await locals.api.delete(`/shelves/${shelf_id}/books?${params.toString()}`);
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
if (!response.ok) {
const message = await response.text();
error(response.status, message);
return await response.json();
}
return await response.json()
});
);
export const createBookshelf = command(bookshelfCreate, async (data) => {
const { locals } = getRequestEvent();
const response = await locals.api.post(`/shelves`, data)
const response = await locals.api.post(`/shelves`, data);
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
return await response.json()
})
return await response.json();
});
+17 -17
View File
@@ -4,40 +4,40 @@ import { error } from '@sveltejs/kit';
import z from 'zod';
export const listDevices = query(async (): Promise<Device[]> => {
const { locals } = getRequestEvent();
const { locals } = getRequestEvent();
const response = await locals.api.get(`/devices`);
const response = await locals.api.get(`/devices`);
if (!response.ok) error(500, 'An unkown error occurred');
if (!response.ok) error(500, 'An unkown error occurred');
const deviceResult = await response.json();
return deviceResult.items
const deviceResult = await response.json();
return deviceResult.items;
});
export const createDevice = form(createDeviceSchema, async (data): Promise<Device> => {
const { locals } = getRequestEvent();
const { locals } = getRequestEvent();
const response = await locals.api.post(`/devices`, data);
const response = await locals.api.post(`/devices`, data);
if (!response.ok) error(500, 'An unknown error occurred');
if (!response.ok) error(500, 'An unknown error occurred');
return await response.json();
return await response.json();
});
export const regenerateDeviceApiKey = command(z.string(), async (deviceId): Promise<Device> => {
const { locals } = getRequestEvent();
const { locals } = getRequestEvent();
const response = await locals.api.get(`/devices/${deviceId}/regenerate`);
const response = await locals.api.get(`/devices/${deviceId}/regenerate`);
if (!response.ok) error(500, 'An unknown error occurred');
if (!response.ok) error(500, 'An unknown error occurred');
return await response.json();
})
return await response.json();
});
export const deleteDevice = command(z.string(), async (deviceId): Promise<void> => {
const { locals } = getRequestEvent();
const { locals } = getRequestEvent();
const response = await locals.api.delete(`/devices/${deviceId}`);
const response = await locals.api.delete(`/devices/${deviceId}`);
if (!response.ok) error(500, 'An unknown error occurred');
if (!response.ok) error(500, 'An unknown error occurred');
});
@@ -13,7 +13,7 @@
</script>
<form
{...createDevice.enhance(async ({ form, submit }) => {
{...createDevice.enhance(async ({ element, submit }) => {
try {
await submit();
@@ -24,7 +24,7 @@
const deviceName = createDevice.fields.name.value();
form.reset();
element.reset();
toast.success(`Device '${deviceName}' created`);
onSuccess?.();
} catch (error) {
@@ -50,10 +50,10 @@
<form
bind:this={formEl}
{...updateBookCover.enhance(async ({ submit, form }) => {
{...updateBookCover.enhance(async ({ submit, element }) => {
try {
await submit();
form.reset();
element.reset();
// Deliberately does not close the dialog. The cover is one panel of a
// larger form now, and closing here would throw away metadata edits
// the reader has not saved yet.
@@ -23,7 +23,7 @@
</Dialog.Header>
<form
{...createLibrary.enhance(async ({ form, submit }) => {
{...createLibrary.enhance(async ({ element, submit }) => {
try {
await submit();
@@ -35,7 +35,7 @@
const libraryName = createLibrary.fields.name.value();
form.reset();
element.reset();
selectedIcon = 'library';
open = false;
toast.success(`Library '${libraryName}' created.`);
@@ -1,35 +1,36 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
let { open = $bindable(), onSubmit }: { open?: boolean, onSubmit: (name: string) => Promise<undefined> } = $props()
let shelfName = $state('')
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
let {
open = $bindable(),
onSubmit
}: { open?: boolean; onSubmit: (name: string) => Promise<undefined> } = $props();
let shelfName = $state('');
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>Create bookshelf</Dialog.Title>
</Dialog.Header>
<div class="flex items-center gap-4">
<Label>Name</Label>
<Input bind:value={shelfName}/>
</div>
<div class="flex items-center gap-4">
<Label>Name</Label>
<Input bind:value={shelfName} />
</div>
<!-- Buttons -->
<Dialog.Footer class="ml-auto flex">
<Button variant="outline" onclick={() => (open = false)}>Cancel</Button>
<Button
onclick={async () => {
await onSubmit(shelfName);
await onSubmit(shelfName);
}}
variant="default">Create</Button
>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
</Dialog.Root>
@@ -17,7 +17,7 @@
</Card.Header>
<form
{...signup.enhance(async ({ submit, form }) => {
{...signup.enhance(async ({ submit, element }) => {
try {
await submit();
@@ -29,7 +29,7 @@
// Move to login tab on success
// TODO: Fix previous errors showing on login form
form.reset();
element.reset();
toast.success('Successfully registered!');
login.fields.set({ email: '', password: '' });
login.validate();
@@ -28,7 +28,9 @@
// directly in the markup keeps it static.
const header = $derived({
title: 'chitai',
url: resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(libraryState.activeLibrary!.id) })
url: resolve('/(root)/(library)/library/[libraryId]', {
libraryId: String(libraryState.activeLibrary!.id)
})
});
</script>
@@ -2,7 +2,7 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import { Badge } from "$lib/components/ui/badge/index.js";
import { Badge } from '$lib/components/ui/badge/index.js';
import { getLibraryState, LibraryState } from '$lib/state/library.svelte';
import { LIBRARY_ICONS } from '$lib/components/ui/icon-picker/index.js';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
@@ -12,7 +12,9 @@
const libraryState = getLibraryState();
const sidebar = useSidebar();
const activeIcon = $derived(LIBRARY_ICONS[libraryState.activeLibrary?.icon ?? 'library'] ?? LIBRARY_ICONS['library']);
const activeIcon = $derived(
LIBRARY_ICONS[libraryState.activeLibrary?.icon ?? 'library'] ?? LIBRARY_ICONS['library']
);
</script>
<Sidebar.Menu>
@@ -35,7 +37,7 @@
<span class="ml-1 truncate font-semibold">
{libraryState.activeLibrary!.name}
</span>
<span class="ml-1 truncate font-mono text-xs tabular-nums text-muted-foreground">
<span class="ml-1 truncate font-mono text-xs text-muted-foreground tabular-nums">
{libraryState.activeLibrary!.total ?? 0} books
</span>
</div>
@@ -51,15 +53,15 @@
>
<DropdownMenu.Label class="text-xs text-muted-foreground">Libraries</DropdownMenu.Label>
{#each libraryState.libraries as library (library.name)}
{@const LibraryIcon = (LIBRARY_ICONS[library.icon ?? 'library'] ?? LIBRARY_ICONS['library']).component}
{@const LibraryIcon = (
LIBRARY_ICONS[library.icon ?? 'library'] ?? LIBRARY_ICONS['library']
).component}
<DropdownMenu.Item onSelect={() => libraryState.setActive(library.id)} class="gap-2 p-2">
<div class="flex size-6 items-center justify-center rounded-md border">
<LibraryIcon class="size-3.5 shrink-0" />
</div>
{library.name}
<Badge
variant="outline"
class="font-semibold ml-auto">
<Badge variant="outline" class="ml-auto font-semibold">
{library.total ?? 0}
</Badge>
</DropdownMenu.Item>
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -14,10 +14,10 @@
bind:this={ref}
data-slot="avatar-badge"
class={cn(
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
'absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none',
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
className
)}
{...restProps}
@@ -1,6 +1,6 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
bind:ref
data-slot="avatar-fallback"
class={cn(
"rounded-full bg-muted text-muted-foreground flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs",
'flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs',
className
)}
{...restProps}
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -14,7 +14,7 @@
bind:this={ref}
data-slot="avatar-group-count"
class={cn(
"size-8 rounded-full bg-muted text-sm text-muted-foreground group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 relative flex shrink-0 items-center justify-center ring-2 ring-background",
'relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3',
className
)}
{...restProps}
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -14,7 +14,7 @@
bind:this={ref}
data-slot="avatar-group"
class={cn(
"cn-avatar-group group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
'cn-avatar-group group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background',
className
)}
{...restProps}
@@ -1,6 +1,6 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -12,6 +12,6 @@
<AvatarPrimitive.Image
bind:ref
data-slot="avatar-image"
class={cn("rounded-full aspect-square size-full object-cover", className)}
class={cn('aspect-square size-full rounded-full object-cover', className)}
{...restProps}
/>
@@ -1,15 +1,15 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
loadingStatus = $bindable("loading"),
size = "default",
loadingStatus = $bindable('loading'),
size = 'default',
class: className,
...restProps
}: AvatarPrimitive.RootProps & {
size?: "default" | "sm" | "lg";
size?: 'default' | 'sm' | 'lg';
} = $props();
</script>
@@ -19,7 +19,7 @@
data-slot="avatar"
data-size={size}
class={cn(
"size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:border-border after:mix-blend-darken dark:after:mix-blend-lighten",
'group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten',
className
)}
{...restProps}
@@ -1,9 +1,9 @@
import Badge from "./avatar-badge.svelte";
import Fallback from "./avatar-fallback.svelte";
import GroupCount from "./avatar-group-count.svelte";
import Group from "./avatar-group.svelte";
import Image from "./avatar-image.svelte";
import Root from "./avatar.svelte";
import Badge from './avatar-badge.svelte';
import Fallback from './avatar-fallback.svelte';
import GroupCount from './avatar-group-count.svelte';
import Group from './avatar-group.svelte';
import Image from './avatar-image.svelte';
import Root from './avatar.svelte';
export {
Root,
@@ -18,5 +18,5 @@ export {
Fallback as AvatarFallback,
Badge as AvatarBadge,
Group as AvatarGroup,
GroupCount as AvatarGroupCount,
GroupCount as AvatarGroupCount
};
@@ -15,8 +15,7 @@
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
accent:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90'
accent: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90'
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
@@ -6,9 +6,7 @@ import type { WithChildren } from 'bits-ui';
import type { HTMLInputAttributes } from 'svelte/elements';
export type FileRejectedReason =
| 'Maximum file size exceeded'
| 'File type not allowed'
| 'Maximum files uploaded';
'Maximum file size exceeded' | 'File type not allowed' | 'Maximum files uploaded';
export type FileDropZonePropsWithoutHTML = WithChildren<{
ref?: HTMLInputElement | null;
@@ -45,11 +45,7 @@
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-64 p-3" align="start">
<Input
bind:value={search}
placeholder="Search icons..."
class="mb-3 h-8"
/>
<Input bind:value={search} placeholder="Search icons..." class="mb-3 h-8" />
<ScrollArea class="h-48">
<div class="grid grid-cols-6 gap-1">
{#each filteredIcons as [key, icon] (key)}
@@ -58,7 +54,10 @@
<Tooltip.Trigger>
<button
type="button"
class="flex size-8 items-center justify-center rounded-md hover:bg-accent {value === key ? 'bg-accent' : ''}"
class="flex size-8 items-center justify-center rounded-md hover:bg-accent {value ===
key
? 'bg-accent'
: ''}"
onclick={() => selectIcon(key)}
>
<IconComponent class="size-4" />
@@ -59,7 +59,10 @@ import type { Component } from 'svelte';
export type IconName = keyof typeof LIBRARY_ICONS;
export const LIBRARY_ICONS: Record<string, { component: Component; label: string; category: string }> = {
export const LIBRARY_ICONS: Record<
string,
{ component: Component; label: string; category: string }
> = {
// Generic
library: { component: Library, label: 'Library', category: 'Generic' },
'book-open': { component: BookOpen, label: 'Book Open', category: 'Generic' },
@@ -1,8 +1,8 @@
import Root from "./popover.svelte";
import Close from "./popover-close.svelte";
import Content from "./popover-content.svelte";
import Trigger from "./popover-trigger.svelte";
import Portal from "./popover-portal.svelte";
import Root from './popover.svelte';
import Close from './popover-close.svelte';
import Content from './popover-content.svelte';
import Trigger from './popover-trigger.svelte';
import Portal from './popover-portal.svelte';
export {
Root,
@@ -15,5 +15,5 @@ export {
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose,
Portal as PopoverPortal,
Portal as PopoverPortal
};
@@ -1,5 +1,5 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
import { Popover as PopoverPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
</script>
@@ -1,14 +1,14 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
import PopoverPortal from "./popover-portal.svelte";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import { Popover as PopoverPrimitive } from 'bits-ui';
import PopoverPortal from './popover-portal.svelte';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
align = "center",
align = 'center',
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
@@ -23,7 +23,7 @@
{sideOffset}
{align}
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
'z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
{...restProps}
@@ -1,5 +1,5 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
import { Popover as PopoverPrimitive } from 'bits-ui';
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
</script>
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Popover as PopoverPrimitive } from "bits-ui";
import { cn } from '$lib/utils.js';
import { Popover as PopoverPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
@@ -12,6 +12,6 @@
<PopoverPrimitive.Trigger
bind:ref
data-slot="popover-trigger"
class={cn("", className)}
class={cn('', className)}
{...restProps}
/>
@@ -1,5 +1,5 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
import { Popover as PopoverPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
</script>
@@ -1,6 +1,6 @@
import { Tooltip as TooltipPrimitive } from "bits-ui";
import Trigger from "./tooltip-trigger.svelte";
import Content from "./tooltip-content.svelte";
import { Tooltip as TooltipPrimitive } from 'bits-ui';
import Trigger from './tooltip-trigger.svelte';
import Content from './tooltip-content.svelte';
const Root = TooltipPrimitive.Root;
const Provider = TooltipPrimitive.Provider;
@@ -17,5 +17,5 @@ export {
Content as TooltipContent,
Trigger as TooltipTrigger,
Provider as TooltipProvider,
Portal as TooltipPortal,
Portal as TooltipPortal
};
@@ -1,12 +1,12 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Tooltip as TooltipPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
sideOffset = 0,
side = "top",
side = 'top',
children,
arrowClasses,
...restProps
@@ -22,7 +22,7 @@
{sideOffset}
{side}
class={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-tooltip-content-transform-origin) z-50 w-fit text-balance rounded-md px-3 py-1.5 text-xs",
'z-50 w-fit origin-(--bits-tooltip-content-transform-origin) animate-in rounded-md bg-primary px-3 py-1.5 text-xs text-balance text-primary-foreground fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
className
)}
{...restProps}
@@ -32,11 +32,11 @@
{#snippet child({ props })}
<div
class={cn(
"bg-primary z-50 size-2.5 rotate-45 rounded-[2px]",
"data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]",
"data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]",
"data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2",
"data-[side=left]:-translate-y-[calc(50%_-_3px)]",
'z-50 size-2.5 rotate-45 rounded-[2px] bg-primary',
'data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]',
'data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]',
'data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2',
'data-[side=left]:-translate-y-[calc(50%_-_3px)]',
arrowClasses
)}
{...props}
@@ -1,5 +1,5 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
import { Tooltip as TooltipPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: TooltipPrimitive.TriggerProps = $props();
</script>
@@ -49,7 +49,8 @@
Read
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => bookOps.downloadBookFile(book.id, book.files[0].id, book.files[0].filename)}
onclick={() =>
bookOps.downloadBookFile(book.id, book.files[0].id, book.files[0].filename)}
>
<Download class="size-4" />
Download
@@ -111,7 +111,11 @@
<Spinner />
</div>
{:else if bookCollection.books.length > 0}
<ScrollArea bind:viewportRef={scrollContainer} orientation="both" class="h-[calc(100vh-11rem)] w-full px-5 pb-5">
<ScrollArea
bind:viewportRef={scrollContainer}
orientation="both"
class="h-[calc(100vh-11rem)] w-full px-5 pb-5"
>
{#if bookCollection.view === 'grid'}
<BookGrid books={bookCollection.books} />
{:else if bookCollection.view === 'list'}
@@ -170,7 +174,7 @@
</Sidebar.Inset>
<!-- Filter Sidebar (right-side) -->
<FilterSidebar class="m-2 pb-5 h-full pt-20" />
<FilterSidebar class="m-2 h-full pt-20 pb-5" />
</Sidebar.Provider>
</div>
</div>
@@ -73,8 +73,8 @@
bookOps.deleteDialogTitle = `Delete "${book.title}"?`;
bookOps.deleteFn = async (deleteFiles: boolean) => {
await bookOps.deleteBooks([book.id], deleteFiles);
libraryState.activeLibrary!.total!--
bookshelfState.deletedBooks([book])
libraryState.activeLibrary!.total!--;
bookshelfState.deletedBooks([book]);
};
bookOps.deleteDialogOpen = true;
}}
@@ -121,7 +121,10 @@
? 'cursor-pointer'
: ''}"
>
<a href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })} class="shrink-0">
<a
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
class="shrink-0"
>
<BookCover {book} height={110} />
</a>
@@ -18,7 +18,7 @@
aria-pressed={active}
onclick={() => (active ? bookCollection.clearView() : bookCollection.applyPreset(preset))}
class="shrink-0 rounded-full border px-3 py-1 text-xs whitespace-nowrap transition-colors {active
? 'border-primary bg-primary text-primary-foreground font-medium'
? 'border-primary bg-primary font-medium text-primary-foreground'
: 'border-border text-muted-foreground hover:border-muted-foreground hover:text-foreground'}"
>
{preset.label}
@@ -19,9 +19,7 @@
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} relative"
>
{#if bookCollection.hasActiveSort}
<div
class="absolute top-[5px] right-[5px] h-1.5 w-1.5 rounded-full bg-flag"
></div>
<div class="absolute top-[5px] right-[5px] h-1.5 w-1.5 rounded-full bg-flag"></div>
{/if}
<ArrowUpDown />
</DropdownMenu.Trigger>
+1 -1
View File
@@ -19,7 +19,7 @@ export const bookshelfCreate = z.object({
title: z.string().min(1, 'Must have a title'),
library_id: stringCoerce.optional(),
book_ids: stringArrayCoerce.optional()
})
});
export type BookshelfQuerySchema = typeof bookshelfQuerySchema;
export type ModifyBooksInShelf = typeof modifyBooksInShelf;
+3 -3
View File
@@ -1,8 +1,8 @@
import { z } from 'zod';
import type { components } from './openapi/schema';
export type Device = components['schemas']['KosyncDeviceRead']
export type Device = components['schemas']['KosyncDeviceRead'];
export const createDeviceSchema = z.object({
name: z.string().min(1, 'Name cannot be empty')
})
name: z.string().min(1, 'Name cannot be empty')
});
+5 -1
View File
@@ -1 +1,5 @@
export const BACKEND_API_URL = import.meta.env.VITE_BACKEND_API_URL || 'http://localhost:8000';
import { env } from '$env/dynamic/private';
// Read at runtime, not build time: `import.meta.env` is substituted by Vite during the build, so a
// published image would carry whatever the build host had — see docs/ci-release-pipeline.md.
export const BACKEND_API_URL = env.VITE_BACKEND_API_URL || 'http://localhost:8000';
@@ -1,16 +1,11 @@
import { invalidate } from '$app/navigation';
import { page } from '$app/state';
import {
deleteBookFiles,
deleteBooks,
listBooks,
updateBookProgress
} from '$lib/api';
import { deleteBookFiles, deleteBooks, listBooks, updateBookProgress } from '$lib/api';
import {
type Book,
type UpdateBookProgress,
type BookQuery,
type PaginatedResponse,
type PaginatedResponse
} from '$lib/schema';
import { getContext, setContext } from 'svelte';
import { toast } from 'svelte-sonner';
+7 -11
View File
@@ -7,12 +7,9 @@ export class BookSelectionState {
readonly selectionModeActive = $derived(this.selectedBooks.size !== 0);
toggleSelection(book: Book) {
const bookId = book.id.toString()
if (this.selectedBooks.has(bookId))
this.selectedBooks.delete(bookId);
else
this.selectedBooks.set(bookId, book);
const bookId = book.id.toString();
if (this.selectedBooks.has(bookId)) this.selectedBooks.delete(bookId);
else this.selectedBooks.set(bookId, book);
}
isSelected(id: number | string): boolean {
@@ -20,11 +17,11 @@ export class BookSelectionState {
}
getSelectedBooks(): Book[] {
return Array.from(this.selectedBooks.values())
return Array.from(this.selectedBooks.values());
}
getSelectedIds(): string[] {
return Array.from(this.selectedBooks.keys())
return Array.from(this.selectedBooks.keys());
}
numSelected() {
@@ -33,9 +30,8 @@ export class BookSelectionState {
selectAll(books: Book[]) {
books.forEach((book) => {
const bookId = book.id.toString()
if (!this.selectedBooks.has(bookId))
this.selectedBooks.set(bookId, book)
const bookId = book.id.toString();
if (!this.selectedBooks.has(bookId)) this.selectedBooks.set(bookId, book);
});
}
+98 -97
View File
@@ -9,16 +9,16 @@ export class BookshelfState {
readonly libraryBookshelves = new SvelteMap<string, Bookshelf[]>();
getBookshelves(libraryId: string | number): Bookshelf[] | undefined {
const id = libraryId.toString();
if (!this.libraryBookshelves.has(id)) {
this.fetchBookshelves(id).then(shelves => {
this.libraryBookshelves.set(id, shelves);
});
}
return this.libraryBookshelves.get(id);
}
const id = libraryId.toString();
if (!this.libraryBookshelves.has(id)) {
this.fetchBookshelves(id).then((shelves) => {
this.libraryBookshelves.set(id, shelves);
});
}
return this.libraryBookshelves.get(id);
}
async fetchBookshelves(libraryId: string) {
try {
@@ -38,108 +38,109 @@ export class BookshelfState {
title: name,
library_id: libraryId,
book_ids: booksToAdd
})
});
if (bookshelf.library_id) {
const currentShelves = this.libraryBookshelves.get(bookshelf.library_id.toString()) || [];
this.libraryBookshelves.set(bookshelf.library_id.toString(), [...currentShelves, bookshelf]);
this.libraryBookshelves.set(bookshelf.library_id.toString(), [
...currentShelves,
bookshelf
]);
}
invalidate('app:books')
invalidate('app:books');
if (booksToAdd?.length)
toast.success(`Added ${booksToAdd.length} books to '${name}'`)
else
toast.success(`Created shelf '${name}'`)
return bookshelf
if (booksToAdd?.length) toast.success(`Added ${booksToAdd.length} books to '${name}'`);
else toast.success(`Created shelf '${name}'`);
return bookshelf;
} catch (error) {
toast.error(`Failed to create bookshelf '${name}'`)
console.error(`Failed to create bookshelf: `, error)
toast.error(`Failed to create bookshelf '${name}'`);
console.error(`Failed to create bookshelf: `, error);
}
}
async addBooksToShelf(shelfId: number | string, bookIds: number[]) {
try {
const shelf = await addBooksToShelf({
shelf_id: shelfId,
book_ids: bookIds
});
this.updateShelf(shelf)
if (bookIds.length === 1) toast.success(`Added book to shelf!`);
else toast.success(`Added ${bookIds.length} books to shelf!`);
} catch (error) {
console.error('Failed to add book(s) to shelf: ', error);
toast.error('Failed to add book(s) to shelf.');
}
}
async removeBooksFromShelf(shelfId: number | string, bookIds: number[]) {
try {
const shelf = await removeBooksFromShelf({
shelf_id: shelfId,
book_ids: bookIds
});
this.updateShelf(shelf)
if (bookIds.length === 1) toast.success(`Removed book from shelf.`);
else toast.success(`Removed ${bookIds.length} books from shelf!`);
} catch (error) {
console.error('Failed to remove book(s) from shelf: ', error);
toast.error('Failed to remove book(s) from shelf.');
}
}
async updateShelf(shelf: Bookshelf) {
if (!shelf.library_id) return;
const bookshelves = this.libraryBookshelves.get(shelf.library_id.toString())
if (!bookshelves) return;
const index = bookshelves.findIndex(bookshelf => bookshelf.id === shelf.id)
if (index !== -1) {
this.libraryBookshelves.set(
shelf.library_id.toString(),
[...bookshelves.slice(0, index), shelf, ...bookshelves.slice(index + 1)]
);
}
}
deletedBooks(books: Book[]) {
// Assume all books are in the same library
const libraryId = books[0].library_id.toString()
const bookshelves = this.libraryBookshelves.get(libraryId);
if (!bookshelves) return;
// Create a Set of shelf IDs that need updating for efficient lookup
const shelfIdsToUpdate = new Set<number>();
books.forEach(book => {
book.lists.forEach(shelf => {
shelfIdsToUpdate.add(shelf.id);
});
try {
const shelf = await addBooksToShelf({
shelf_id: shelfId,
book_ids: bookIds
});
const updatedBookshelves = bookshelves.map(shelf => {
if (shelfIdsToUpdate.has(shelf.id)) {
// Count how many times this shelf appears across all deleted books
let decrementBy = 0;
books.forEach(book => {
if (book.lists.some(s => s.id === shelf.id)) {
decrementBy++;
}
});
return { ...shelf, total: shelf.total - decrementBy };
}
return shelf;
});
this.libraryBookshelves.set(libraryId, updatedBookshelves);
this.updateShelf(shelf);
if (bookIds.length === 1) toast.success(`Added book to shelf!`);
else toast.success(`Added ${bookIds.length} books to shelf!`);
} catch (error) {
console.error('Failed to add book(s) to shelf: ', error);
toast.error('Failed to add book(s) to shelf.');
}
}
async removeBooksFromShelf(shelfId: number | string, bookIds: number[]) {
try {
const shelf = await removeBooksFromShelf({
shelf_id: shelfId,
book_ids: bookIds
});
this.updateShelf(shelf);
if (bookIds.length === 1) toast.success(`Removed book from shelf.`);
else toast.success(`Removed ${bookIds.length} books from shelf!`);
} catch (error) {
console.error('Failed to remove book(s) from shelf: ', error);
toast.error('Failed to remove book(s) from shelf.');
}
}
async updateShelf(shelf: Bookshelf) {
if (!shelf.library_id) return;
const bookshelves = this.libraryBookshelves.get(shelf.library_id.toString());
if (!bookshelves) return;
const index = bookshelves.findIndex((bookshelf) => bookshelf.id === shelf.id);
if (index !== -1) {
this.libraryBookshelves.set(shelf.library_id.toString(), [
...bookshelves.slice(0, index),
shelf,
...bookshelves.slice(index + 1)
]);
}
}
deletedBooks(books: Book[]) {
// Assume all books are in the same library
const libraryId = books[0].library_id.toString();
const bookshelves = this.libraryBookshelves.get(libraryId);
if (!bookshelves) return;
// Create a Set of shelf IDs that need updating for efficient lookup
const shelfIdsToUpdate = new Set<number>();
books.forEach((book) => {
book.lists.forEach((shelf) => {
shelfIdsToUpdate.add(shelf.id);
});
});
const updatedBookshelves = bookshelves.map((shelf) => {
if (shelfIdsToUpdate.has(shelf.id)) {
// Count how many times this shelf appears across all deleted books
let decrementBy = 0;
books.forEach((book) => {
if (book.lists.some((s) => s.id === shelf.id)) {
decrementBy++;
}
});
return { ...shelf, total: shelf.total - decrementBy };
}
return shelf;
});
this.libraryBookshelves.set(libraryId, updatedBookshelves);
}
}
const BOOKSHELF_KEY = Symbol('BOOKSHELF');
export function setBookshelfState() {
+6 -3
View File
@@ -32,9 +32,12 @@ export class LibraryState {
this.activeLibrary = this.libraries.find((lib) => lib.id === libraryId) || this.libraries[0];
if (browser) {
localStorage.setItem('previousLibraryId', this.activeLibrary.id.toString());
await goto(resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(libraryId) }), {
invalidate: ['app:libraries']
});
await goto(
resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(libraryId) }),
{
invalidate: ['app:libraries']
}
);
}
}
+8 -2
View File
@@ -44,7 +44,10 @@ export const FONT_STACKS: { label: string; value: string }[] = [
{ label: 'Palatino', value: "'Palatino Linotype', Palatino, 'Book Antiqua', serif" },
{ label: 'Helvetica', value: "'Helvetica Neue', Helvetica, Arial, sans-serif" },
{ label: 'Verdana', value: 'Verdana, Geneva, sans-serif' },
{ label: 'Monospace', value: "ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace" }
{
label: 'Monospace',
value: "ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace"
}
];
export type Palette = Record<string, string>;
@@ -299,7 +302,10 @@ export function parseThemeCookie(raw: string | undefined | null): ThemeConfig {
try {
const parsed = JSON.parse(decodeURIComponent(raw));
if (!parsed || typeof parsed !== 'object') return { preset: DEFAULT_PRESET_ID };
return { ...parsed, preset: typeof parsed.preset === 'string' ? parsed.preset : DEFAULT_PRESET_ID };
return {
...parsed,
preset: typeof parsed.preset === 'string' ? parsed.preset : DEFAULT_PRESET_ID
};
} catch {
return { preset: DEFAULT_PRESET_ID };
}
+3 -3
View File
@@ -35,9 +35,9 @@ export class ThemeState {
readonly isCustomised = $derived(
Boolean(
this.config.radius ||
this.config.fonts ||
Object.keys(this.config.light ?? {}).length ||
Object.keys(this.config.dark ?? {}).length
this.config.fonts ||
Object.keys(this.config.light ?? {}).length ||
Object.keys(this.config.dark ?? {}).length
)
);
@@ -1,15 +1,14 @@
<script lang="ts">
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import * as Empty from '$lib/components/ui/empty/index.js'
import { Button } from '$lib/components/ui/button/index.js'
import * as Empty from '$lib/components/ui/empty/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import BookList from '$lib/components/view/book-list.svelte';
import { FolderX, Upload } from '@lucide/svelte';
import { getBookOperationsState } from '$lib/state/bookOperations.svelte.js';
let { data } = $props();
let bookOps = getBookOperationsState()
let bookOps = getBookOperationsState();
</script>
<ScrollArea class="h-full w-full">
@@ -22,7 +21,7 @@
<!-- Empty when no preset returned anything, i.e. the library has no books -->
{#if data.presets.every((preset) => data.shelves[preset.id].length === 0)}
<div class="flex flex-col items-center justify-center w-full h-full">
<div class="flex h-full w-full flex-col items-center justify-center">
<!-- Show a CTA to upload books if the library is empty -->
<Empty.Root class="mb-[12vh]">
<Empty.Header>
@@ -44,7 +44,6 @@
});
</script>
<div class="[--header-height:calc(--spacing(14))]">
<Sidebar.Provider class="flex flex-col">
<div class="flex h-screen">
@@ -71,7 +71,8 @@
<!-- Colours -->
<section class="flex flex-col gap-3">
<Label class="text-xs font-medium tracking-wider text-muted-foreground uppercase">Colours</Label>
<Label class="text-xs font-medium tracking-wider text-muted-foreground uppercase">Colours</Label
>
<div class="grid gap-x-6 gap-y-1 sm:grid-cols-2">
{#each COLOR_TOKENS as token (token.key)}
<div class="flex items-center gap-3 rounded-md px-2 py-2 hover:bg-muted">
@@ -94,7 +95,8 @@
<!-- Radius -->
<section class="flex flex-col gap-3">
<Label class="text-xs font-medium tracking-wider text-muted-foreground uppercase">Corners</Label>
<Label class="text-xs font-medium tracking-wider text-muted-foreground uppercase">Corners</Label
>
<div class="flex flex-wrap gap-2">
{#each RADII as option (option.value)}
<button
@@ -138,7 +140,8 @@
<!-- Preview -->
<section class="flex flex-col gap-3">
<Label class="text-xs font-medium tracking-wider text-muted-foreground uppercase">Preview</Label>
<Label class="text-xs font-medium tracking-wider text-muted-foreground uppercase">Preview</Label
>
<div class="rounded-lg border bg-sidebar p-4">
<div class="flex flex-col gap-4 rounded-lg border bg-card p-4">
<div class="flex items-baseline justify-between gap-4">
@@ -1,9 +1,9 @@
import { listDevices } from "$lib/api/device.remote";
import { listDevices } from '$lib/api/device.remote';
export async function load() {
const devices = await listDevices();
const devices = await listDevices();
return {
devices
};
return {
devices
};
}

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