Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e898069b03 | ||
|
|
b33f57d942 | ||
|
|
54043a97d2 | ||
|
|
45b03764d2 | ||
|
|
b70ed5cb51 |
@@ -1,6 +1,15 @@
|
|||||||
# Change this secret to something random
|
# Change this secret to something random
|
||||||
CHITAI_TOKEN_SECRET=secret
|
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)
|
# Setup the path to your initial library (optional)
|
||||||
CHITAI_DEFAULT_LIBRARY_NAME=Books
|
CHITAI_DEFAULT_LIBRARY_NAME=Books
|
||||||
CHITAI_DEFAULT_LIBRARY_PATH="libraries/books"
|
CHITAI_DEFAULT_LIBRARY_PATH="libraries/books"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -103,6 +103,11 @@ API docs are served by the running backend at `http://localhost:8000/schema/` (S
|
|||||||
manually against a live backend).
|
manually against a live backend).
|
||||||
- **Migrations are mandatory.** The app runs with `create_all=False`, so a model change without a
|
- **Migrations are mandatory.** The app runs with `create_all=False`, so a model change without a
|
||||||
matching Alembic revision will not reach the database.
|
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:`.
|
- **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`
|
- The three `README.md` files are user-facing. Agent-facing knowledge belongs in the `AGENTS.md`
|
||||||
files.
|
files.
|
||||||
|
|||||||
@@ -26,12 +26,18 @@ eBook library management application.
|
|||||||
|
|
||||||
### Installation (Production Deployment)
|
### 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`
|
2. Navigate to the project root: `cd chitai`
|
||||||
3. Build the Docker images: `docker compose build`
|
3. Copy the example environment file: `cp .env.prod-example .env`
|
||||||
4. Copy the example environment file and configure: `cp .env.prod-example .env`
|
4. Edit `.env`. At minimum set `CHITAI_TOKEN_SECRET` to something random, and set
|
||||||
5. Run the Docker containers in detached mode: `docker compose up -d`
|
`CHITAI_ORIGIN` to the URL you will reach the app on — logging in fails with a 403 if it
|
||||||
6. Access the frontend at: `http://localhost:3000/`
|
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
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
`litestar run` is the CLI development runner. Production should invoke uvicorn or granian
|
||||||
directly, with a worker count.
|
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
|
Formatting, linting and tests now run in CI (`.gitea/workflows/ci.yml`) and block, and the
|
||||||
114 errors — 100 of them unused imports, the rest bare `except`, unused variables and
|
tree is clean against them. What is still missing is a type checker: nothing runs one despite
|
||||||
`== True` comparisons. `ruff check --fix` clears 51 automatically.
|
`# type: ignore` comments in the tree.
|
||||||
|
|
||||||
There is no `[tool.ruff]` section in `pyproject.toml`, so only ruff's default `E4/E7/E9/F`
|
`pyproject.toml` gained a `[tool.ruff.lint.per-file-ignores]` section for `__init__.py`
|
||||||
rules run, and no type checker is configured at all despite `# type: ignore` comments in
|
re-exports, but no rule selection — so only ruff's default `E4/E7/E9/F` rules run. Widening
|
||||||
the tree. Individually these are trivial; collectively they say nothing runs on commit.
|
that set is worthwhile and will surface a fresh batch of findings.
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ dev = [
|
|||||||
"pytest-databases[postgres]>=0.15.0",
|
"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]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ oauth2_auth = OAuth2PasswordBearerAuth[User](
|
|||||||
|
|
||||||
watcher_task: asyncio.Task
|
watcher_task: asyncio.Task
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def setup_db_connection(app: Litestar) -> AsyncGenerator[None, None]:
|
async def setup_db_connection(app: Litestar) -> AsyncGenerator[None, None]:
|
||||||
# Setup databse
|
# Setup databse
|
||||||
@@ -97,7 +98,7 @@ async def setup_db_connection(app: Litestar) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def setup_directory_watcher(app: Litestar) -> AsyncGenerator[None, None]:
|
async def setup_directory_watcher(app: Litestar) -> AsyncGenerator[None, None]:
|
||||||
|
|
||||||
# Create book covers directory if it does not exist
|
# Create book covers directory if it does not exist
|
||||||
await create_directory(settings.book_cover_path)
|
await create_directory(settings.book_cover_path)
|
||||||
# Create consume directory
|
# Create consume directory
|
||||||
@@ -107,14 +108,17 @@ async def setup_directory_watcher(app: Litestar) -> AsyncGenerator[None, None]:
|
|||||||
book_service = BookService(session=db_session)
|
book_service = BookService(session=db_session)
|
||||||
library_service = LibraryService(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())
|
watcher_task = asyncio.create_task(file_watcher.init_watcher())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
watcher_task.cancel()
|
watcher_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> Litestar:
|
def create_app() -> Litestar:
|
||||||
return Litestar(
|
return Litestar(
|
||||||
route_handlers=[
|
route_handlers=[
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# src/chitai/controllers/access.py
|
# src/chitai/controllers/access.py
|
||||||
|
|
||||||
# Standard library
|
# Standard library
|
||||||
from typing import Annotated, Any
|
from typing import Annotated
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# Third-party libraries
|
# Third-party libraries
|
||||||
|
|||||||
@@ -4,9 +4,8 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
# Third-party libraries
|
# Third-party libraries
|
||||||
from litestar import Controller, post, get, patch, delete
|
from litestar import Controller, get
|
||||||
from litestar.params import Dependency
|
from litestar.params import Dependency
|
||||||
from litestar.exceptions import HTTPException
|
|
||||||
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
|
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
|
||||||
from advanced_alchemy.service.pagination import OffsetPagination
|
from advanced_alchemy.service.pagination import OffsetPagination
|
||||||
from advanced_alchemy.service import FilterTypeT
|
from advanced_alchemy.service import FilterTypeT
|
||||||
|
|||||||
@@ -8,47 +8,51 @@ from litestar import Controller, post, get, delete
|
|||||||
from litestar.di import Provide
|
from litestar.di import Provide
|
||||||
from chitai.services import dependencies as deps
|
from chitai.services import dependencies as deps
|
||||||
|
|
||||||
class DeviceController(Controller):
|
|
||||||
""" Controller for managing KOReader devices."""
|
|
||||||
|
|
||||||
dependencies = {
|
class DeviceController(Controller):
|
||||||
"device_service": Provide(deps.provide_kosync_device_service)
|
"""Controller for managing KOReader devices."""
|
||||||
}
|
|
||||||
|
dependencies = {"device_service": Provide(deps.provide_kosync_device_service)}
|
||||||
|
|
||||||
path = "/devices"
|
path = "/devices"
|
||||||
|
|
||||||
|
|
||||||
@get()
|
@get()
|
||||||
async def get_devices(self, device_service: KosyncDeviceService, current_user: User) -> OffsetPagination[KosyncDeviceRead]:
|
async def get_devices(
|
||||||
""" Return a list of all the user's devices."""
|
self, device_service: KosyncDeviceService, current_user: User
|
||||||
devices = await device_service.list(
|
) -> OffsetPagination[KosyncDeviceRead]:
|
||||||
KosyncDevice.user_id == current_user.id
|
"""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)
|
return device_service.to_schema(devices, schema_type=KosyncDeviceRead)
|
||||||
|
|
||||||
@post()
|
@post()
|
||||||
async def create_device(self, data: KosyncDeviceCreate, device_service: KosyncDeviceService, current_user: User) -> KosyncDeviceRead:
|
async def create_device(
|
||||||
device = await device_service.create({
|
self,
|
||||||
'name': data.name,
|
data: KosyncDeviceCreate,
|
||||||
'user_id': current_user.id
|
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)
|
return device_service.to_schema(device, schema_type=KosyncDeviceRead)
|
||||||
|
|
||||||
@delete("/{device_id:int}")
|
@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
|
# Ensure the device exists and is owned by the user
|
||||||
device = await device_service.get_one(
|
device = await device_service.get_one(
|
||||||
KosyncDevice.id == device_id,
|
KosyncDevice.id == device_id, KosyncDevice.user_id == current_user.id
|
||||||
KosyncDevice.user_id == current_user.id
|
|
||||||
)
|
)
|
||||||
await device_service.delete(device.id)
|
await device_service.delete(device.id)
|
||||||
|
|
||||||
@get("/{device_id:int}/regenerate")
|
@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
|
# Ensure the device exists and is owned by the user
|
||||||
device = await device_service.get_one(
|
device = await device_service.get_one(
|
||||||
KosyncDevice.id == device_id,
|
KosyncDevice.id == device_id, KosyncDevice.user_id == current_user.id
|
||||||
KosyncDevice.user_id == current_user.id
|
|
||||||
)
|
)
|
||||||
updated_device = await device_service.regenerate_api_key(device.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,
|
user: m.User,
|
||||||
) -> KosyncProgressRead:
|
) -> KosyncProgressRead:
|
||||||
"""Return the Kosync progress record associated with the given document."""
|
"""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:
|
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(
|
return KosyncProgressRead(
|
||||||
document=progress.document,
|
document=progress.document,
|
||||||
@@ -85,6 +89,3 @@ class KosyncController(Controller):
|
|||||||
detail="User accounts must be created via the main application",
|
detail="User accounts must be created via the main application",
|
||||||
status_code=HTTP_403_FORBIDDEN,
|
status_code=HTTP_403_FORBIDDEN,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from typing import Annotated
|
|||||||
# Third-party libraries
|
# Third-party libraries
|
||||||
import aiofiles
|
import aiofiles
|
||||||
from aiofiles import os as aios
|
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.enums import RequestEncodingType
|
||||||
from litestar.params import Body, Dependency
|
from litestar.params import Body, Dependency
|
||||||
from litestar.exceptions import HTTPException
|
from litestar.exceptions import HTTPException
|
||||||
@@ -89,7 +89,9 @@ class LibraryController(Controller):
|
|||||||
Injected Dependencies:
|
Injected Dependencies:
|
||||||
library_service: The service used to query and return library data.
|
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(
|
return library_service.to_schema(
|
||||||
results, total, filters, schema_type=LibraryRead
|
results, total, filters, schema_type=LibraryRead
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
from chitai.services import dependencies as deps
|
from chitai.services import dependencies as deps
|
||||||
from chitai.database import models as m
|
from chitai.database import models as m
|
||||||
from chitai.services.author import AuthorService
|
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.publisher import PublisherLibraryFilter
|
||||||
from chitai.services.filters.tags import TagLibraryFilter
|
from chitai.services.filters.tags import TagLibraryFilter
|
||||||
from chitai.services.opds.models import Entry, Link, LinkTypes, LinkRelations
|
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 import BookService, ShelfService, LibraryService
|
||||||
from chitai.services.publisher import PublisherService
|
from chitai.services.publisher import PublisherService
|
||||||
from chitai.services.tag import TagService
|
from chitai.services.tag import TagService
|
||||||
@@ -14,7 +21,7 @@ from litestar import Controller, Request, Response, get
|
|||||||
from litestar.response import File
|
from litestar.response import File
|
||||||
from litestar.di import Provide
|
from litestar.di import Provide
|
||||||
from litestar.exceptions import HTTPException
|
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 chitai.middleware.basic_auth import basic_auth_mw
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
@@ -22,11 +29,10 @@ from litestar.params import Dependency
|
|||||||
from advanced_alchemy.service import FilterTypeT
|
from advanced_alchemy.service import FilterTypeT
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class OpdsController(Controller):
|
class OpdsController(Controller):
|
||||||
""" Controller for managing OPDS endpoints """
|
"""Controller for managing OPDS endpoints"""
|
||||||
|
|
||||||
middleware=[basic_auth_mw]
|
middleware = [basic_auth_mw]
|
||||||
|
|
||||||
dependencies = {
|
dependencies = {
|
||||||
"user": Provide(deps.provide_user_via_basic_auth),
|
"user": Provide(deps.provide_user_via_basic_auth),
|
||||||
@@ -76,32 +82,30 @@ class OpdsController(Controller):
|
|||||||
title=lib.name,
|
title=lib.name,
|
||||||
href=f"/opds/library/{lib.id}",
|
href=f"/opds/library/{lib.id}",
|
||||||
rel=LinkRelations.SUBSECTION,
|
rel=LinkRelations.SUBSECTION,
|
||||||
type=LinkTypes.NAVIGATION
|
type=LinkTypes.NAVIGATION,
|
||||||
)
|
)
|
||||||
]
|
],
|
||||||
) for lib in libraries
|
)
|
||||||
|
for lib in libraries
|
||||||
]
|
]
|
||||||
|
|
||||||
feed = create_navigation_feed(
|
feed = create_navigation_feed(
|
||||||
id="/opds",
|
id="/opds",
|
||||||
title="Root",
|
title="Root",
|
||||||
self_url="/opds",
|
self_url="/opds",
|
||||||
links=[
|
links=[
|
||||||
Link(
|
Link(
|
||||||
rel="search",
|
rel="search",
|
||||||
href="/opds/opensearch",
|
href="/opds/opensearch",
|
||||||
type="application/opensearchdescription+xml",
|
type="application/opensearchdescription+xml",
|
||||||
title="Search books",
|
title="Search books",
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
entries=entries
|
entries=entries,
|
||||||
)
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
feed,
|
|
||||||
media_type="application/xml"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return Response(feed, media_type="application/xml")
|
||||||
|
|
||||||
@get("/acquisition")
|
@get("/acquisition")
|
||||||
async def get_acquisition_feed(
|
async def get_acquisition_feed(
|
||||||
self,
|
self,
|
||||||
@@ -109,37 +113,41 @@ class OpdsController(Controller):
|
|||||||
feed_id: str,
|
feed_id: str,
|
||||||
feed_title: str,
|
feed_title: str,
|
||||||
books_service: BookService,
|
books_service: BookService,
|
||||||
book_filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [],
|
book_filters: Annotated[
|
||||||
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = []
|
list[FilterTypeT], Dependency(skip_validation=True)
|
||||||
|
] = [],
|
||||||
|
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [],
|
||||||
) -> Response:
|
) -> Response:
|
||||||
|
|
||||||
all_filters = [*filters, *book_filters]
|
all_filters = [*filters, *book_filters]
|
||||||
books, total = await books_service.list_and_count(*all_filters)
|
books, total = await books_service.list_and_count(*all_filters)
|
||||||
|
|
||||||
limit, offset = extract_limit_offset(all_filters)
|
limit, offset = extract_limit_offset(all_filters)
|
||||||
|
|
||||||
links = []
|
links = []
|
||||||
|
|
||||||
# Create pagination links if it is a paginated feed
|
# 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(
|
pagination = create_pagination_links(
|
||||||
request=request,
|
request=request,
|
||||||
total=total,
|
total=total,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
feed_title=feed_title,
|
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
|
# 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))
|
links.append(create_search_link(request))
|
||||||
|
|
||||||
# Create self URL
|
# Create self URL
|
||||||
self_url = f"{request.url.path}?{urlencode(list(request.query_params.items()), doseq=True)}"
|
self_url = f"{request.url.path}?{urlencode(list(request.query_params.items()), doseq=True)}"
|
||||||
|
|
||||||
feed = create_acquisition_feed(
|
feed = create_acquisition_feed(
|
||||||
id=feed_id,
|
id=feed_id,
|
||||||
title=feed_title,
|
title=feed_title,
|
||||||
@@ -147,28 +155,26 @@ class OpdsController(Controller):
|
|||||||
books=books,
|
books=books,
|
||||||
links=links,
|
links=links,
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response(feed, media_type="application/xml")
|
|
||||||
|
|
||||||
|
return Response(feed, media_type="application/xml")
|
||||||
|
|
||||||
@get("/opensearch")
|
@get("/opensearch")
|
||||||
async def opensearch(self, user: m.User, request: Request) -> Response:
|
async def opensearch(self, user: m.User, request: Request) -> Response:
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
get_opensearch_document(
|
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}")
|
@get("/library/{library_id:int}")
|
||||||
async def get_library_feed(self, library: m.Library) -> Response:
|
async def get_library_feed(self, library: m.Library) -> Response:
|
||||||
|
|
||||||
feed = create_library_navigation_feed(library)
|
feed = create_library_navigation_feed(library)
|
||||||
|
|
||||||
return Response(feed, media_type="application/xml")
|
return Response(feed, media_type="application/xml")
|
||||||
|
|
||||||
|
|
||||||
@get("/library/{library_id:int}/{collection_type:str}")
|
@get("/library/{library_id:int}/{collection_type:str}")
|
||||||
async def get_library_collection_feed(
|
async def get_library_collection_feed(
|
||||||
self,
|
self,
|
||||||
@@ -180,39 +186,51 @@ class OpdsController(Controller):
|
|||||||
tag_service: TagService,
|
tag_service: TagService,
|
||||||
publisher_service: PublisherService,
|
publisher_service: PublisherService,
|
||||||
request: Request,
|
request: Request,
|
||||||
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = []
|
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [],
|
||||||
) -> Response:
|
) -> Response:
|
||||||
|
|
||||||
service_map = {
|
service_map = {
|
||||||
'shelves': (shelf_service, lambda: shelf_service.list_and_count(
|
"shelves": (
|
||||||
*filters,
|
shelf_service,
|
||||||
CollectionFilter("library_id", values=[library.id]),
|
lambda: shelf_service.list_and_count(
|
||||||
OrderBy("name", "asc"),
|
*filters,
|
||||||
m.BookList.user_id == user.id
|
CollectionFilter("library_id", values=[library.id]),
|
||||||
)),
|
OrderBy("name", "asc"),
|
||||||
'tags': (tag_service, lambda: tag_service.list_and_count(
|
m.BookList.user_id == user.id,
|
||||||
*filters,
|
),
|
||||||
TagLibraryFilter(libraries=[library.id]),
|
),
|
||||||
OrderBy("name", "asc"),
|
"tags": (
|
||||||
uniquify=True,
|
tag_service,
|
||||||
)),
|
lambda: tag_service.list_and_count(
|
||||||
'authors': (author_service, lambda: author_service.list_and_count(
|
*filters,
|
||||||
*filters,
|
TagLibraryFilter(libraries=[library.id]),
|
||||||
AuthorLibraryFilter(libraries=[library.id]),
|
OrderBy("name", "asc"),
|
||||||
OrderBy("name", "asc"),
|
uniquify=True,
|
||||||
uniquify=True
|
),
|
||||||
)),
|
),
|
||||||
'publishers': (publisher_service, lambda: publisher_service.list_and_count(
|
"authors": (
|
||||||
*filters,
|
author_service,
|
||||||
PublisherLibraryFilter(libraries=[library.id]),
|
lambda: author_service.list_and_count(
|
||||||
OrderBy("name", "asc"),
|
*filters,
|
||||||
uniquify=True
|
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:
|
if collection_type not in service_map:
|
||||||
raise HTTPException(status_code=404, detail="Collection type not found")
|
raise HTTPException(status_code=404, detail="Collection type not found")
|
||||||
|
|
||||||
_, fetch_items = service_map[collection_type]
|
_, fetch_items = service_map[collection_type]
|
||||||
items, total = await fetch_items()
|
items, total = await fetch_items()
|
||||||
links = []
|
links = []
|
||||||
@@ -220,41 +238,40 @@ class OpdsController(Controller):
|
|||||||
# Create pagination links if it is a paginated feed
|
# Create pagination links if it is a paginated feed
|
||||||
limit, offset = extract_limit_offset(filters)
|
limit, offset = extract_limit_offset(filters)
|
||||||
|
|
||||||
if request.query_params.get('paginated'):
|
if request.query_params.get("paginated"):
|
||||||
pagination = create_pagination_links(
|
pagination = create_pagination_links(
|
||||||
request=request,
|
request=request,
|
||||||
total=total,
|
total=total,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
feed_title=collection_type,
|
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)
|
feed = create_collection_navigation_feed(library, collection_type, items, links)
|
||||||
return Response(feed, media_type="application/xml")
|
return Response(feed, media_type="application/xml")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@get("/search")
|
@get("/search")
|
||||||
async def search_books(
|
async def search_books(
|
||||||
self, books_service: BookService,
|
self,
|
||||||
|
books_service: BookService,
|
||||||
request: Request,
|
request: Request,
|
||||||
book_filters: Annotated[
|
book_filters: Annotated[
|
||||||
list[FilterTypeT], Dependency(skip_validation=True)
|
list[FilterTypeT], Dependency(skip_validation=True)
|
||||||
] = [],
|
] = [],
|
||||||
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = []
|
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [],
|
||||||
) -> Response:
|
) -> Response:
|
||||||
|
|
||||||
filters = [*filters, *book_filters]
|
filters = [*filters, *book_filters]
|
||||||
|
|
||||||
books, total = await books_service.list_and_count(
|
books, total = await books_service.list_and_count(*filters)
|
||||||
*filters
|
|
||||||
)
|
|
||||||
|
|
||||||
limit, offset = extract_limit_offset(filters)
|
limit, offset = extract_limit_offset(filters)
|
||||||
|
|
||||||
# Create pagination links
|
# Create pagination links
|
||||||
pagination = create_pagination_links(
|
pagination = create_pagination_links(
|
||||||
request=request,
|
request=request,
|
||||||
@@ -262,37 +279,34 @@ class OpdsController(Controller):
|
|||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
feed_title="Search Results",
|
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]
|
links = [link for link in [pagination.next_link, pagination.prev_link] if link]
|
||||||
|
|
||||||
catalog_xml = create_acquisition_feed(
|
catalog_xml = create_acquisition_feed(
|
||||||
id=f"/opds/search?q=q",
|
id="/opds/search?q=q",
|
||||||
title="Search results",
|
title="Search results",
|
||||||
url=f"/opds/search?q=q",
|
url="/opds/search?q=q",
|
||||||
books=books,
|
books=books,
|
||||||
links=links
|
links=links,
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response(catalog_xml, media_type="application/xml")
|
return Response(catalog_xml, media_type="application/xml")
|
||||||
|
|
||||||
@get(path="download/{book_id:int}/{file_id:int}")
|
@get(path="download/{book_id:int}/{file_id:int}")
|
||||||
async def get_file(
|
async def get_file(
|
||||||
self, book_id: int, file_id: int, books_service: BookService
|
self, book_id: int, file_id: int, books_service: BookService
|
||||||
) -> File:
|
) -> File:
|
||||||
|
|
||||||
return await books_service.get_file(book_id, file_id)
|
return await books_service.get_file(book_id, file_id)
|
||||||
|
|
||||||
|
|
||||||
def extract_limit_offset(filters: list[FilterTypeT]) -> tuple[int, int]:
|
def extract_limit_offset(filters: list[FilterTypeT]) -> tuple[int, int]:
|
||||||
"""Extract page size and offset from filters"""
|
"""Extract page size and offset from filters"""
|
||||||
limit_offset_filter = next(
|
limit_offset_filter = next((f for f in filters if isinstance(f, LimitOffset)), None)
|
||||||
(f for f in filters if isinstance(f, LimitOffset)),
|
|
||||||
None
|
|
||||||
)
|
|
||||||
|
|
||||||
if limit_offset_filter:
|
if limit_offset_filter:
|
||||||
return limit_offset_filter.limit, limit_offset_filter.offset
|
return limit_offset_filter.limit, limit_offset_filter.offset
|
||||||
|
|
||||||
raise ValueError("LimitOffset filter not found")
|
raise ValueError("LimitOffset filter not found")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
# Third-party libraries
|
# Third-party libraries
|
||||||
from litestar import Controller, post, get, patch, delete
|
from litestar import Controller, get
|
||||||
from litestar.params import Dependency
|
from litestar.params import Dependency
|
||||||
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
|
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
|
||||||
from advanced_alchemy.service.pagination import OffsetPagination
|
from advanced_alchemy.service.pagination import OffsetPagination
|
||||||
|
|||||||
@@ -70,7 +70,9 @@ class BookshelfController(Controller):
|
|||||||
filters.append(CollectionFilter("library_id", values=libraries))
|
filters.append(CollectionFilter("library_id", values=libraries))
|
||||||
filters.append(m.BookList.user_id == current_user.id)
|
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)
|
return shelf_service.to_schema(results, total, filters, schema_type=ShelfRead)
|
||||||
|
|
||||||
@post()
|
@post()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
# Third-party libraries
|
# Third-party libraries
|
||||||
from litestar import Controller, post, get, patch, delete
|
from litestar import Controller, get
|
||||||
from litestar.params import Dependency
|
from litestar.params import Dependency
|
||||||
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
|
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
|
||||||
from advanced_alchemy.service.pagination import OffsetPagination
|
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
|
return len(self.book_links) if self.book_links else 0
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class BookListLink(BigIntBase):
|
class BookListLink(BigIntBase):
|
||||||
__tablename__ = "book_list_links"
|
__tablename__ = "book_list_links"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from sqlalchemy import ForeignKey
|
from sqlalchemy import ForeignKey
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from advanced_alchemy.base import BigIntAuditBase
|
from advanced_alchemy.base import BigIntAuditBase
|
||||||
|
|
||||||
|
|
||||||
@@ -20,5 +19,5 @@ class BookProgress(BigIntAuditBase):
|
|||||||
pdf_page: Mapped[Optional[int]]
|
pdf_page: Mapped[Optional[int]]
|
||||||
percentage: Mapped[float]
|
percentage: Mapped[float]
|
||||||
completed: Mapped[Optional[bool]]
|
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]]
|
device_id: Mapped[Optional[str]]
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
from collections.abc import Hashable
|
from collections.abc import Hashable
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
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.base import BigIntAuditBase
|
||||||
from advanced_alchemy.mixins import UniqueMixin
|
from advanced_alchemy.mixins import UniqueMixin
|
||||||
|
|
||||||
from .book import Book
|
|
||||||
|
|
||||||
|
|
||||||
class BookSeries(BigIntAuditBase, UniqueMixin):
|
class BookSeries(BigIntAuditBase, UniqueMixin):
|
||||||
__tablename__ = "book_series"
|
__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
|
||||||
from sqlalchemy.orm import mapped_column
|
from sqlalchemy.orm import mapped_column
|
||||||
|
|
||||||
from advanced_alchemy.base import BigIntAuditBase
|
from advanced_alchemy.base import BigIntAuditBase
|
||||||
|
|
||||||
|
|
||||||
class KosyncDevice(BigIntAuditBase):
|
class KosyncDevice(BigIntAuditBase):
|
||||||
__tablename__ = "devices"
|
__tablename__ = "devices"
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class Library(BigIntAuditBase, SlugKey):
|
|||||||
name: Mapped[str] = mapped_column(unique=True)
|
name: Mapped[str] = mapped_column(unique=True)
|
||||||
root_path: Mapped[str]
|
root_path: Mapped[str]
|
||||||
# Which structure to save the files in the filesystem (i.e {author_name}/{title}.{ext})
|
# Which structure to save the files in the filesystem (i.e {author_name}/{title}.{ext})
|
||||||
path_template: Mapped[str]
|
path_template: Mapped[str]
|
||||||
description: Mapped[Optional[str]]
|
description: Mapped[Optional[str]]
|
||||||
icon: Mapped[str] = mapped_column(default="library")
|
icon: Mapped[str] = mapped_column(default="library")
|
||||||
read_only: Mapped[bool] = mapped_column(nullable=False, default=False)
|
read_only: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
from litestar import Request, Response, MediaType
|
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
|
from advanced_alchemy.exceptions import NotFoundError
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
from base64 import b64decode
|
from base64 import b64decode
|
||||||
from chitai.services.user import UserService
|
from chitai.services.user import UserService
|
||||||
from litestar.middleware import (
|
from litestar.middleware import (
|
||||||
AbstractAuthenticationMiddleware,
|
AbstractAuthenticationMiddleware,
|
||||||
AuthenticationResult,
|
AuthenticationResult,
|
||||||
DefineMiddleware
|
DefineMiddleware,
|
||||||
)
|
)
|
||||||
from litestar.connection import ASGIConnection
|
from litestar.connection import ASGIConnection
|
||||||
from litestar.exceptions import NotAuthorizedException, PermissionDeniedException
|
from litestar.exceptions import NotAuthorizedException, PermissionDeniedException
|
||||||
@@ -11,25 +11,30 @@ from chitai.config import settings
|
|||||||
|
|
||||||
|
|
||||||
class BasicAuthenticationMiddleware(AbstractAuthenticationMiddleware):
|
class BasicAuthenticationMiddleware(AbstractAuthenticationMiddleware):
|
||||||
async def authenticate_request(self, connection: ASGIConnection) -> AuthenticationResult:
|
async def authenticate_request(
|
||||||
"""Given a request, parse the header for Base64 encoded basic auth credentials. """
|
self, connection: ASGIConnection
|
||||||
|
) -> AuthenticationResult:
|
||||||
|
"""Given a request, parse the header for Base64 encoded basic auth credentials."""
|
||||||
|
|
||||||
# retrieve the auth header
|
# retrieve the auth header
|
||||||
auth_header = connection.headers.get("Authorization", None)
|
auth_header = connection.headers.get("Authorization", None)
|
||||||
if not auth_header:
|
if not auth_header:
|
||||||
raise NotAuthorizedException()
|
raise NotAuthorizedException()
|
||||||
|
|
||||||
username, password = b64decode(auth_header.split("Basic ")[1]).decode().split(":")
|
|
||||||
|
|
||||||
|
username, password = (
|
||||||
|
b64decode(auth_header.split("Basic ")[1]).decode().split(":")
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
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_service = UserService(db_session)
|
||||||
user = await user_service.authenticate(username, password)
|
user = await user_service.authenticate(username, password)
|
||||||
return AuthenticationResult(user=user, auth=None)
|
return AuthenticationResult(user=user, auth=None)
|
||||||
|
|
||||||
except PermissionDeniedException:
|
except PermissionDeniedException:
|
||||||
raise NotAuthorizedException()
|
raise NotAuthorizedException()
|
||||||
|
|
||||||
|
|
||||||
basic_auth_mw = DefineMiddleware(BasicAuthenticationMiddleware)
|
basic_auth_mw = DefineMiddleware(BasicAuthenticationMiddleware)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
from chitai.services.user import UserService
|
from chitai.services.user import UserService
|
||||||
from chitai.services.kosync_device import KosyncDeviceService
|
from chitai.services.kosync_device import KosyncDeviceService
|
||||||
from litestar.middleware import (
|
from litestar.middleware import (
|
||||||
AbstractAuthenticationMiddleware,
|
AbstractAuthenticationMiddleware,
|
||||||
AuthenticationResult,
|
AuthenticationResult,
|
||||||
DefineMiddleware
|
DefineMiddleware,
|
||||||
)
|
)
|
||||||
from litestar.connection import ASGIConnection
|
from litestar.connection import ASGIConnection
|
||||||
from litestar.exceptions import NotAuthorizedException, PermissionDeniedException
|
from litestar.exceptions import NotAuthorizedException, PermissionDeniedException
|
||||||
@@ -11,19 +11,23 @@ from chitai.config import settings
|
|||||||
|
|
||||||
|
|
||||||
class KosyncAuthenticationMiddleware(AbstractAuthenticationMiddleware):
|
class KosyncAuthenticationMiddleware(AbstractAuthenticationMiddleware):
|
||||||
async def authenticate_request(self, connection: ASGIConnection) -> AuthenticationResult:
|
async def authenticate_request(
|
||||||
"""Given a request, parse the header for Base64 encoded basic auth credentials. """
|
self, connection: ASGIConnection
|
||||||
|
) -> AuthenticationResult:
|
||||||
|
"""Given a request, parse the header for Base64 encoded basic auth credentials."""
|
||||||
|
|
||||||
# retrieve the auth header
|
# retrieve the auth header
|
||||||
api_key = connection.headers.get("X-AUTH-USER", None)
|
api_key = connection.headers.get("X-AUTH-USER", None)
|
||||||
if not api_key:
|
if not api_key:
|
||||||
raise NotAuthorizedException()
|
raise NotAuthorizedException()
|
||||||
|
|
||||||
try:
|
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_service = UserService(db_session)
|
||||||
device_service = KosyncDeviceService(db_session)
|
device_service = KosyncDeviceService(db_session)
|
||||||
|
|
||||||
device = await device_service.get_by_api_key(api_key)
|
device = await device_service.get_by_api_key(api_key)
|
||||||
user = await user_service.get(device.user_id)
|
user = await user_service.get(device.user_id)
|
||||||
|
|
||||||
@@ -32,6 +36,6 @@ class KosyncAuthenticationMiddleware(AbstractAuthenticationMiddleware):
|
|||||||
except PermissionDeniedException as exc:
|
except PermissionDeniedException as exc:
|
||||||
print(exc)
|
print(exc)
|
||||||
raise NotAuthorizedException()
|
raise NotAuthorizedException()
|
||||||
|
|
||||||
|
|
||||||
kosync_api_key_auth = DefineMiddleware(KosyncAuthenticationMiddleware)
|
kosync_api_key_auth = DefineMiddleware(KosyncAuthenticationMiddleware)
|
||||||
|
|||||||
@@ -273,5 +273,3 @@ class BookProgressCreate(BaseModel):
|
|||||||
completed: bool | None = None
|
completed: bool | None = None
|
||||||
device_type: str | None = None
|
device_type: str | None = None
|
||||||
device_id: str | None = None
|
device_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from pathlib import Path
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from pydantic import BaseModel, ConfigDict, Field, SkipValidation, computed_field
|
from pydantic import BaseModel, ConfigDict, Field, SkipValidation, computed_field
|
||||||
from litestar.datastructures import UploadFile
|
from litestar.datastructures import UploadFile
|
||||||
from advanced_alchemy.utils.text import slugify
|
from advanced_alchemy.utils.text import slugify
|
||||||
|
|
||||||
|
|
||||||
class LibraryCreate(BaseModel):
|
class LibraryCreate(BaseModel):
|
||||||
name: Annotated[str, Field(min_length=1)]
|
name: Annotated[str, Field(min_length=1)]
|
||||||
@@ -17,6 +17,7 @@ class LibraryCreate(BaseModel):
|
|||||||
def slug(self) -> str:
|
def slug(self) -> str:
|
||||||
return slugify(self.name)
|
return slugify(self.name)
|
||||||
|
|
||||||
|
|
||||||
class LibraryRead(BaseModel):
|
class LibraryRead(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
class ShelfRead(BaseModel):
|
class ShelfRead(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
library_id: int | None = None
|
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):
|
class ShelfCreate(BaseModel):
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from advanced_alchemy.service import (
|
|||||||
SQLAlchemyAsyncRepositoryService,
|
SQLAlchemyAsyncRepositoryService,
|
||||||
ModelDictT,
|
ModelDictT,
|
||||||
is_dict,
|
is_dict,
|
||||||
schema_dump
|
schema_dump,
|
||||||
)
|
)
|
||||||
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
|
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
|
||||||
from advanced_alchemy.filters import CollectionFilter
|
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
|
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.
|
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
|
repository_type = Repo
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def create_book(
|
async def create_book(
|
||||||
self,
|
self,
|
||||||
data: ModelDictT[Book],
|
data: ModelDictT[Book],
|
||||||
@@ -647,7 +647,10 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
statement = (
|
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:
|
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
|
ValueError: If fewer than two distinct books were named, one of them does
|
||||||
not exist, or they do not all belong to one library.
|
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:
|
if not merged_ids:
|
||||||
raise ValueError("A merge needs at least two different books")
|
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
|
# 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.
|
# reader who has been through the EPUB and not the PDF.
|
||||||
rows = (
|
rows = (
|
||||||
await session.execute(
|
(
|
||||||
select(BookProgress)
|
await session.execute(
|
||||||
.where(BookProgress.book_id.in_([survivor_id, *merged_ids]))
|
select(BookProgress)
|
||||||
.order_by(BookProgress.percentage.desc())
|
.where(BookProgress.book_id.in_([survivor_id, *merged_ids]))
|
||||||
|
.order_by(BookProgress.percentage.desc())
|
||||||
|
)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
furthest: dict[int, BookProgress] = {}
|
furthest: dict[int, BookProgress] = {}
|
||||||
for progress in rows:
|
for progress in rows:
|
||||||
@@ -902,14 +911,19 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
|
|||||||
# and only one of them however many books offered it.
|
# and only one of them however many books offered it.
|
||||||
held_names = select(Identifier.name).where(Identifier.book_id == survivor_id)
|
held_names = select(Identifier.name).where(Identifier.book_id == survivor_id)
|
||||||
incoming = (
|
incoming = (
|
||||||
await session.execute(
|
(
|
||||||
select(Identifier)
|
await session.execute(
|
||||||
.where(
|
select(Identifier)
|
||||||
Identifier.book_id.in_(merged_ids), Identifier.name.notin_(held_names)
|
.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()
|
taken: set[str] = set()
|
||||||
for identifier in incoming:
|
for identifier in incoming:
|
||||||
@@ -932,15 +946,19 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
|
|||||||
merged = set(merged_ids)
|
merged = set(merged_ids)
|
||||||
|
|
||||||
rows = (
|
rows = (
|
||||||
await session.execute(
|
(
|
||||||
select(DuplicateDismissal).where(
|
await session.execute(
|
||||||
or_(
|
select(DuplicateDismissal).where(
|
||||||
DuplicateDismissal.book_a_id.in_(merged_ids),
|
or_(
|
||||||
DuplicateDismissal.book_b_id.in_(merged_ids),
|
DuplicateDismissal.book_a_id.in_(merged_ids),
|
||||||
|
DuplicateDismissal.book_b_id.in_(merged_ids),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
existing = await self._dismissed_pairs()
|
existing = await self._dismissed_pairs()
|
||||||
doomed: list[int] = []
|
doomed: list[int] = []
|
||||||
@@ -1025,7 +1043,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
|
|||||||
# copies meet as long as they agree on any one of them.
|
# copies meet as long as they agree on any one of them.
|
||||||
for title in titles:
|
for title in titles:
|
||||||
for author in authors:
|
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()
|
dismissed = await self._dismissed_pairs()
|
||||||
series = {book_id: _series_position(book) for book_id, book in books.items()}
|
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")
|
raise ValueError("A book cannot be dismissed against itself")
|
||||||
|
|
||||||
found = (
|
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:
|
if len(set(found)) != 2:
|
||||||
raise ValueError("No such book")
|
raise ValueError("No such book")
|
||||||
@@ -1305,7 +1331,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
result.books.append(book)
|
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
|
return result
|
||||||
|
|
||||||
@@ -1344,7 +1372,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
|
|||||||
consume_path: Path,
|
consume_path: Path,
|
||||||
library: Library,
|
library: Library,
|
||||||
allow_duplicates: bool = False,
|
allow_duplicates: bool = False,
|
||||||
**kwargs
|
**kwargs,
|
||||||
) -> ImportResult:
|
) -> ImportResult:
|
||||||
"""
|
"""
|
||||||
Import files that are already on disk, from the consume directory.
|
Import files that are already on disk, from the consume directory.
|
||||||
@@ -1368,7 +1396,6 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
|
|||||||
result = ImportResult()
|
result = ImportResult()
|
||||||
file_groups: dict[Path, list[Path]] = defaultdict(list)
|
file_groups: dict[Path, list[Path]] = defaultdict(list)
|
||||||
|
|
||||||
|
|
||||||
for file_path in file_paths:
|
for file_path in file_paths:
|
||||||
rel_path = file_path.relative_to(consume_path)
|
rel_path = file_path.relative_to(consume_path)
|
||||||
parent_rel = rel_path.parent
|
parent_rel = rel_path.parent
|
||||||
@@ -1379,11 +1406,10 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
|
|||||||
group_key = rel_path # Use the file itself as the key
|
group_key = rel_path # Use the file itself as the key
|
||||||
else:
|
else:
|
||||||
group_key = parent_rel # Use parent directory as key
|
group_key = parent_rel # Use parent directory as key
|
||||||
|
|
||||||
# Add to appropriate group
|
# Add to appropriate group
|
||||||
file_groups[group_key].append(file_path)
|
file_groups[group_key].append(file_path)
|
||||||
|
|
||||||
|
|
||||||
# For each grouping
|
# For each grouping
|
||||||
for group, files in file_groups.items():
|
for group, files in file_groups.items():
|
||||||
# Fingerprinted before anything moves, since the paths are about to change.
|
# 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)
|
cleanup_empty_parent_directories(consume_path / group, consume_path)
|
||||||
continue
|
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._parse_metadata_from_files(data, root_path=consume_path)
|
||||||
await self._save_cover_image(data)
|
await self._save_cover_image(data)
|
||||||
|
|
||||||
@@ -1446,7 +1472,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
|
|||||||
|
|
||||||
book = await super().create(data)
|
book = await super().create(data)
|
||||||
result.books.append(book)
|
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()
|
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
|
# What the catalogue claims and what is on disk can disagree: Calibre keeps the
|
||||||
# row when a file is moved away behind its back.
|
# row when a file is moved away behind its back.
|
||||||
present = [
|
present = [
|
||||||
file.path
|
file.path for file in entry.files if await aios.path.isfile(file.path)
|
||||||
for file in entry.files
|
|
||||||
if await aios.path.isfile(file.path)
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if not present:
|
if not present:
|
||||||
reason = (
|
reason = "no files on disk" if entry.files else "no files in the catalogue"
|
||||||
"no files on disk"
|
|
||||||
if entry.files
|
|
||||||
else "no files in the catalogue"
|
|
||||||
)
|
|
||||||
result.skipped.append(
|
result.skipped.append(
|
||||||
UnimportedBook(
|
UnimportedBook(
|
||||||
calibre_id=entry.calibre_id, title=entry.title, reason=reason
|
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
|
# TODO: Move only the files associated with the book instead of the whole directory
|
||||||
await move_dir_contents(book.path, updated_path)
|
await move_dir_contents(book.path, updated_path)
|
||||||
data["path"] = str(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(
|
async def add_files(
|
||||||
self,
|
self,
|
||||||
@@ -1979,7 +2005,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
|
|||||||
data["files"] = files
|
data["files"] = files
|
||||||
new_files = await self._save_book_files(library, data, fingerprints)
|
new_files = await self._save_book_files(library, data, fingerprints)
|
||||||
book.files.extend(new_files)
|
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
|
@staticmethod
|
||||||
async def _restore_file(
|
async def _restore_file(
|
||||||
@@ -2067,22 +2095,24 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
|
|||||||
model_data = await self._populate_with_unique_relationships(data)
|
model_data = await self._populate_with_unique_relationships(data)
|
||||||
|
|
||||||
return model_data
|
return model_data
|
||||||
|
|
||||||
def _preprocess_book_data(self, data: dict) -> dict:
|
def _preprocess_book_data(self, data: dict) -> dict:
|
||||||
"""Transform API input format to model format."""
|
"""Transform API input format to model format."""
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return data
|
return data
|
||||||
|
|
||||||
# Transform dict identifiers to list of Identifier objects
|
# Transform dict identifiers to list of Identifier objects
|
||||||
if "identifiers" in data and isinstance(data["identifiers"], dict):
|
if "identifiers" in data and isinstance(data["identifiers"], dict):
|
||||||
data["identifiers"] = [
|
data["identifiers"] = [
|
||||||
Identifier(name=key, value=val)
|
Identifier(name=key, value=val)
|
||||||
for key, val in data["identifiers"].items()
|
for key, val in data["identifiers"].items()
|
||||||
]
|
]
|
||||||
|
|
||||||
return data
|
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.
|
Ensure relationship entities (authors, series, tags, etc.) are unique in the database.
|
||||||
|
|
||||||
@@ -2307,7 +2337,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
|
|||||||
data["files"] = file_metadata
|
data["files"] = file_metadata
|
||||||
return data["files"]
|
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.
|
Extract metadata (title, author, etc.) from book files.
|
||||||
|
|
||||||
@@ -2319,7 +2351,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
|
|||||||
Returns:
|
Returns:
|
||||||
The data with extracted metadata populated in empty fields.
|
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
|
# Add missing fields and update empty (falsey) fields with extracted metadata
|
||||||
for attr in extracted_metadata.keys():
|
for attr in extracted_metadata.keys():
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
# src/chitai/services/bookshelf.py
|
# src/chitai/services/bookshelf.py
|
||||||
|
|
||||||
# Third-party libraries
|
# 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.service import SQLAlchemyAsyncRepositoryService
|
||||||
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
|
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
|
||||||
from advanced_alchemy.utils.dataclass import Empty, EmptyType
|
|
||||||
from sqlalchemy import ColumnElement, Select, delete
|
|
||||||
|
|
||||||
# Local imports
|
# Local imports
|
||||||
from chitai.database.models.book_list import BookList, BookListLink
|
from chitai.database.models.book_list import BookList, BookListLink
|
||||||
|
|||||||
@@ -297,7 +297,9 @@ class CalibreLibrary:
|
|||||||
|
|
||||||
async def count(self) -> int:
|
async def count(self) -> int:
|
||||||
"""How many books the catalogue holds, without reading any of them."""
|
"""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])
|
return int(rows[0][0])
|
||||||
|
|
||||||
async def books(self) -> list[CalibreBook]:
|
async def books(self) -> list[CalibreBook]:
|
||||||
@@ -393,7 +395,9 @@ class CalibreLibrary:
|
|||||||
calibre_id=book_id,
|
calibre_id=book_id,
|
||||||
uuid=str(uuid or ""),
|
uuid=str(uuid or ""),
|
||||||
title=str(title 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)),
|
description=strip_html(descriptions.get(book_id)),
|
||||||
published_date=parse_date(pubdate),
|
published_date=parse_date(pubdate),
|
||||||
series=in_series,
|
series=in_series,
|
||||||
@@ -510,8 +514,19 @@ class _TextExtractor(HTMLParser):
|
|||||||
# a description of three paragraphs comes out as one run-on sentence.
|
# a description of three paragraphs comes out as one run-on sentence.
|
||||||
_BREAKS = frozenset(
|
_BREAKS = frozenset(
|
||||||
{
|
{
|
||||||
"p", "br", "div", "li", "tr", "blockquote", "hr",
|
"p",
|
||||||
"h1", "h2", "h3", "h4", "h5", "h6",
|
"br",
|
||||||
|
"div",
|
||||||
|
"li",
|
||||||
|
"tr",
|
||||||
|
"blockquote",
|
||||||
|
"hr",
|
||||||
|
"h1",
|
||||||
|
"h2",
|
||||||
|
"h3",
|
||||||
|
"h4",
|
||||||
|
"h5",
|
||||||
|
"h6",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,23 @@ from collections import defaultdict
|
|||||||
from chitai.config import settings
|
from chitai.config import settings
|
||||||
from chitai.database.models.library import Library
|
from chitai.database.models.library import Library
|
||||||
from chitai.services import BookService, LibraryService
|
from chitai.services import BookService, LibraryService
|
||||||
from chitai.services.metadata_extractor import Extractor
|
|
||||||
from chitai.services.utils import create_directory
|
from chitai.services.utils import create_directory
|
||||||
from watchfiles import awatch, Change
|
from watchfiles import awatch, Change
|
||||||
|
|
||||||
|
|
||||||
class ConsumeDirectoryWatcher:
|
class ConsumeDirectoryWatcher:
|
||||||
"""Watches a directory and batch processes files by their relative path."""
|
"""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.
|
Initialize the file watcher.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
watch_path: Directory path to watch
|
watch_path: Directory path to watch
|
||||||
batch_delay: Seconds to wait before processing a batch
|
batch_delay: Seconds to wait before processing a batch
|
||||||
@@ -41,9 +47,9 @@ class ConsumeDirectoryWatcher:
|
|||||||
for change_type, file_path in changes:
|
for change_type, file_path in changes:
|
||||||
if change_type != Change.added:
|
if change_type != Change.added:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
file_path = Path(file_path)
|
file_path = Path(file_path)
|
||||||
|
|
||||||
# If a directory was added, scan it for existing files
|
# If a directory was added, scan it for existing files
|
||||||
if file_path.is_dir():
|
if file_path.is_dir():
|
||||||
print(f"Directory added: {file_path}")
|
print(f"Directory added: {file_path}")
|
||||||
@@ -51,28 +57,28 @@ class ConsumeDirectoryWatcher:
|
|||||||
else:
|
else:
|
||||||
print(f"File added: {file_path}")
|
print(f"File added: {file_path}")
|
||||||
await self._handle_file_added(file_path)
|
await self._handle_file_added(file_path)
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
print("File watcher stopped")
|
print("File watcher stopped")
|
||||||
# Wait for any pending processing tasks
|
# Wait for any pending processing tasks
|
||||||
if self._processing_tasks:
|
if self._processing_tasks:
|
||||||
await asyncio.gather(*self._processing_tasks, return_exceptions=True)
|
await asyncio.gather(*self._processing_tasks, return_exceptions=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def _handle_file_added(self, file_path: Path):
|
async def _handle_file_added(self, file_path: Path):
|
||||||
"""Handle a single file being added."""
|
"""Handle a single file being added."""
|
||||||
# Get relative path from watch directory
|
# Get relative path from watch directory
|
||||||
|
|
||||||
rel_path = file_path.relative_to(self.watch_path)
|
rel_path = file_path.relative_to(self.watch_path)
|
||||||
parent_rel = rel_path.parent
|
parent_rel = rel_path.parent
|
||||||
library = parent_rel.parts[0]
|
library = parent_rel.parts[0]
|
||||||
|
|
||||||
# Add to appropriate group
|
# Add to appropriate group
|
||||||
self.file_groups[library].add(file_path)
|
self.file_groups[library].add(file_path)
|
||||||
|
|
||||||
# Schedule batch processing for this group
|
# Schedule batch processing for this group
|
||||||
self._schedule_batch_processing(library)
|
self._schedule_batch_processing(library)
|
||||||
|
|
||||||
async def _handle_directory_added(self, dir_path: Path):
|
async def _handle_directory_added(self, dir_path: Path):
|
||||||
"""Handle a directory being added - scan it for existing files."""
|
"""Handle a directory being added - scan it for existing files."""
|
||||||
try:
|
try:
|
||||||
@@ -83,35 +89,34 @@ class ConsumeDirectoryWatcher:
|
|||||||
await self._handle_file_added(file_path)
|
await self._handle_file_added(file_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error scanning directory {dir_path}: {e}")
|
print(f"Error scanning directory {dir_path}: {e}")
|
||||||
|
|
||||||
def _schedule_batch_processing(self, library_slug: str):
|
def _schedule_batch_processing(self, library_slug: str):
|
||||||
"""Schedule batch processing for a specific path group."""
|
"""Schedule batch processing for a specific path group."""
|
||||||
# Create a task to process this group after a delay
|
# Create a task to process this group after a delay
|
||||||
task = asyncio.create_task(self._delayed_batch_process(library_slug))
|
task = asyncio.create_task(self._delayed_batch_process(library_slug))
|
||||||
self._processing_tasks.add(task)
|
self._processing_tasks.add(task)
|
||||||
task.add_done_callback(self._processing_tasks.discard)
|
task.add_done_callback(self._processing_tasks.discard)
|
||||||
|
|
||||||
async def _delayed_batch_process(self, library_slug: str):
|
async def _delayed_batch_process(self, library_slug: str):
|
||||||
"""Wait for batch delay, then process accumulated files."""
|
"""Wait for batch delay, then process accumulated files."""
|
||||||
await asyncio.sleep(self.batch_delay)
|
await asyncio.sleep(self.batch_delay)
|
||||||
|
|
||||||
# Get and clear the file list for this path
|
# Get and clear the file list for this path
|
||||||
if library_slug not in self.file_groups:
|
if library_slug not in self.file_groups:
|
||||||
return
|
return
|
||||||
|
|
||||||
files_to_process = self.file_groups[library_slug].copy()
|
files_to_process = self.file_groups[library_slug].copy()
|
||||||
self.file_groups[library_slug].clear()
|
self.file_groups[library_slug].clear()
|
||||||
|
|
||||||
if not files_to_process:
|
if not files_to_process:
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"Batch processing {len(files_to_process)} files from {library_slug}")
|
print(f"Batch processing {len(files_to_process)} files from {library_slug}")
|
||||||
await self._process_batch(files_to_process, library_slug)
|
await self._process_batch(files_to_process, library_slug)
|
||||||
|
|
||||||
async def _process_batch(self, file_paths: set[Path], library_slug: str):
|
async def _process_batch(self, file_paths: set[Path], library_slug: str):
|
||||||
"""Process a batch of files."""
|
"""Process a batch of files."""
|
||||||
try:
|
try:
|
||||||
|
|
||||||
result = await self.book_service.create_many_from_existing_files(
|
result = await self.book_service.create_many_from_existing_files(
|
||||||
list(file_paths),
|
list(file_paths),
|
||||||
self.watch_path / Path(library_slug),
|
self.watch_path / Path(library_slug),
|
||||||
@@ -138,10 +143,9 @@ class ConsumeDirectoryWatcher:
|
|||||||
f"already be in the library as: {names}"
|
f"already be in the library as: {names}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error processing batch: {e}")
|
print(f"Error processing batch: {e}")
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
async def _get_library(self, slug: str) -> Library:
|
async def _get_library(self, slug: str) -> Library:
|
||||||
return await self.library_service.get_one(Library.slug == slug)
|
return await self.library_service.get_one(Library.slug == slug)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Standard library
|
# Standard library
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import Any, AsyncGenerator, Callable, NotRequired, Optional
|
from typing import Any, AsyncGenerator, Optional
|
||||||
|
|
||||||
# Third-party libraries
|
# Third-party libraries
|
||||||
from advanced_alchemy.extensions.litestar.providers import (
|
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.exceptions import NotFoundError
|
||||||
from advanced_alchemy.filters import CollectionFilter, StatementFilter
|
from advanced_alchemy.filters import CollectionFilter, StatementFilter
|
||||||
from advanced_alchemy.service import FilterTypeT
|
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from litestar import Request
|
from litestar import Request
|
||||||
@@ -25,7 +24,6 @@ from litestar.di import Provide
|
|||||||
from advanced_alchemy.extensions.litestar.providers import create_filter_dependencies
|
from advanced_alchemy.extensions.litestar.providers import create_filter_dependencies
|
||||||
|
|
||||||
# Local imports
|
# Local imports
|
||||||
from chitai import schemas as s
|
|
||||||
from chitai.database import models as m
|
from chitai.database import models as m
|
||||||
from chitai.services import (
|
from chitai.services import (
|
||||||
UserService,
|
UserService,
|
||||||
@@ -154,7 +152,6 @@ def create_book_filter_dependencies(
|
|||||||
|
|
||||||
# OVERRIDE: Custom search filter with trigram search
|
# OVERRIDE: Custom search filter with trigram search
|
||||||
if config.get("search"):
|
if config.get("search"):
|
||||||
search_fields = config.get("search")
|
|
||||||
|
|
||||||
def provide_trigram_search_filter(
|
def provide_trigram_search_filter(
|
||||||
search_string: str | None = Parameter(
|
search_string: str | None = Parameter(
|
||||||
@@ -370,6 +367,7 @@ def provide_optional_user(request: Request[m.User, Token, Any]) -> m.User | None
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def provide_user_via_basic_auth(request: Request[m.User, None, Any]) -> m.User:
|
async def provide_user_via_basic_auth(request: Request[m.User, None, Any]) -> m.User:
|
||||||
return request.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_device_service = create_service_provider(KosyncDeviceService)
|
||||||
|
|
||||||
provide_kosync_progress_service = create_service_provider(KosyncProgressService)
|
provide_kosync_progress_service = create_service_provider(KosyncProgressService)
|
||||||
|
|
||||||
@@ -4,9 +4,7 @@ from pathlib import Path
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from jinja2 import Template
|
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.
|
# TODO: Replace Jinja2 templates with a simpler custom templating system.
|
||||||
# Current Jinja2 implementation is overly complex for basic path generation.
|
# 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 dataclasses import dataclass
|
||||||
|
|
||||||
from advanced_alchemy.filters import (
|
from advanced_alchemy.filters import (
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ from typing import Any, Optional
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from sqlalchemy.orm import aliased
|
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 (
|
from advanced_alchemy.filters import (
|
||||||
StatementTypeT,
|
StatementTypeT,
|
||||||
StatementFilter,
|
StatementFilter,
|
||||||
CollectionFilter,
|
|
||||||
ModelT,
|
ModelT,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -135,7 +134,7 @@ class ProgressFilter(StatementFilter):
|
|||||||
status_conditions.append(
|
status_conditions.append(
|
||||||
and_(
|
and_(
|
||||||
or_(
|
or_(
|
||||||
m.BookProgress.completed == False,
|
m.BookProgress.completed.is_(False),
|
||||||
m.BookProgress.completed.is_(None),
|
m.BookProgress.completed.is_(None),
|
||||||
),
|
),
|
||||||
m.BookProgress.percentage > 0,
|
m.BookProgress.percentage > 0,
|
||||||
@@ -143,7 +142,7 @@ class ProgressFilter(StatementFilter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if ProgressStatus.READ in self.statuses:
|
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:
|
if ProgressStatus.UNREAD in self.statuses:
|
||||||
status_conditions.append(m.BookProgress.id.is_(None))
|
status_conditions.append(m.BookProgress.id.is_(None))
|
||||||
@@ -154,6 +153,7 @@ class ProgressFilter(StatementFilter):
|
|||||||
@dataclass
|
@dataclass
|
||||||
class FileFilter(StatementFilter):
|
class FileFilter(StatementFilter):
|
||||||
"""Filter books that are related to the given files."""
|
"""Filter books that are related to the given files."""
|
||||||
|
|
||||||
file_ids: list[int]
|
file_ids: list[int]
|
||||||
|
|
||||||
def append_to_statement(
|
def append_to_statement(
|
||||||
@@ -165,17 +165,21 @@ class FileFilter(StatementFilter):
|
|||||||
|
|
||||||
return super().append_to_statement(statement, model, *args, **kwargs)
|
return super().append_to_statement(statement, model, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class FileHashFilter(StatementFilter):
|
class FileHashFilter(StatementFilter):
|
||||||
file_hashes: list[str]
|
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(
|
statement = statement.where(
|
||||||
m.Book.files.any(m.FileMetadata.hash.in_(self.file_hashes))
|
m.Book.files.any(m.FileMetadata.hash.in_(self.file_hashes))
|
||||||
)
|
)
|
||||||
|
|
||||||
return super().append_to_statement(statement, model, *args, **kwargs)
|
return super().append_to_statement(statement, model, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CustomOrderBy(StatementFilter):
|
class CustomOrderBy(StatementFilter):
|
||||||
"""Order by filter with support for 'random' and 'last accessed' orderings."""
|
"""Order by filter with support for 'random' and 'last accessed' orderings."""
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import secrets
|
import secrets
|
||||||
from chitai.database.models.kosync_device import KosyncDevice
|
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
|
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
|
||||||
|
|
||||||
|
|
||||||
class KosyncDeviceService(SQLAlchemyAsyncRepositoryService[KosyncDevice]):
|
class KosyncDeviceService(SQLAlchemyAsyncRepositoryService[KosyncDevice]):
|
||||||
"""Service for managing KOReader devices."""
|
"""Service for managing KOReader devices."""
|
||||||
|
|
||||||
API_KEY_LENGTH_IN_BYTES = 8
|
API_KEY_LENGTH_IN_BYTES = 8
|
||||||
|
|
||||||
class Repo(SQLAlchemyAsyncRepository[KosyncDevice]):
|
class Repo(SQLAlchemyAsyncRepository[KosyncDevice]):
|
||||||
""" Repository for KosyncDevice entities."""
|
"""Repository for KosyncDevice entities."""
|
||||||
|
|
||||||
model_type = KosyncDevice
|
model_type = KosyncDevice
|
||||||
|
|
||||||
@@ -18,18 +23,17 @@ class KosyncDeviceService(SQLAlchemyAsyncRepositoryService[KosyncDevice]):
|
|||||||
|
|
||||||
async def create(self, data: ModelDictT[KosyncDevice], **kwargs) -> KosyncDevice:
|
async def create(self, data: ModelDictT[KosyncDevice], **kwargs) -> KosyncDevice:
|
||||||
data = schema_dump(data)
|
data = schema_dump(data)
|
||||||
data['api_key'] = self._generate_api_key()
|
data["api_key"] = self._generate_api_key()
|
||||||
return await super().create(data, **kwargs)
|
return await super().create(data, **kwargs)
|
||||||
|
|
||||||
async def get_by_api_key(self, api_key: str) -> KosyncDevice:
|
async def get_by_api_key(self, api_key: str) -> KosyncDevice:
|
||||||
return await self.get_one(KosyncDevice.api_key == api_key)
|
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)
|
device = await self.get(device_id)
|
||||||
api_key = self._generate_api_key()
|
api_key = self._generate_api_key()
|
||||||
device.api_key = api_key
|
device.api_key = api_key
|
||||||
return await self.update(device)
|
return await self.update(device)
|
||||||
|
|
||||||
|
|
||||||
def _generate_api_key(self) -> str:
|
def _generate_api_key(self) -> str:
|
||||||
return secrets.token_hex(self.API_KEY_LENGTH_IN_BYTES)
|
return secrets.token_hex(self.API_KEY_LENGTH_IN_BYTES)
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ class KosyncProgressService(SQLAlchemyAsyncRepositoryService[KosyncProgress]):
|
|||||||
|
|
||||||
repository_type = Repo
|
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."""
|
"""Get progress for a specific document and user."""
|
||||||
return await self.get_one_or_none(
|
return await self.get_one_or_none(
|
||||||
KosyncProgress.user_id == user_id,
|
KosyncProgress.user_id == user_id,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from pathlib import Path
|
|||||||
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
|
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
|
||||||
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
|
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
|
||||||
from advanced_alchemy import service
|
from advanced_alchemy import service
|
||||||
from advanced_alchemy.utils.text import slugify
|
|
||||||
|
|
||||||
# Local imports
|
# Local imports
|
||||||
from chitai.database.models.library import Library
|
from chitai.database.models.library import Library
|
||||||
@@ -18,6 +17,7 @@ from chitai.services.utils import (
|
|||||||
|
|
||||||
from chitai.config import settings
|
from chitai.config import settings
|
||||||
|
|
||||||
|
|
||||||
class LibraryService(SQLAlchemyAsyncRepositoryService[Library]):
|
class LibraryService(SQLAlchemyAsyncRepositoryService[Library]):
|
||||||
"""Service for managing libraries and their configuration."""
|
"""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?
|
# 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}'")
|
raise ValueError(f"Library already exists at '{library.root_path}'")
|
||||||
|
|
||||||
if library.read_only:
|
if library.read_only:
|
||||||
@@ -57,8 +57,7 @@ class LibraryService(SQLAlchemyAsyncRepositoryService[Library]):
|
|||||||
raise DirectoryDoesNotExist(
|
raise DirectoryDoesNotExist(
|
||||||
f"Root directory '{library.root_path}' must exist for a read-only library"
|
f"Root directory '{library.root_path}' must exist for a read-only library"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# TODO: Verify the read-only library has read permissions
|
# TODO: Verify the read-only library has read permissions
|
||||||
created_library = await super().create(
|
created_library = await super().create(
|
||||||
service.schema_dump(library, exclude_unset=False), **kwargs
|
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))
|
await create_directory(Path(settings.consume_path) / Path(library.slug))
|
||||||
|
|
||||||
return created_library
|
return created_library
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: Implement library deletion and optional file deletion
|
# TODO: Implement library deletion and optional file deletion
|
||||||
async def delete(
|
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:
|
# Numbered editions, in the forms covers and catalogue records actually use:
|
||||||
# "3rd Edition", "2E", "4e", "8_e", "/6e", "(2nd edition)", "Third International Edition".
|
# "3rd Edition", "2E", "4e", "8_e", "/6e", "(2nd edition)", "Third International Edition".
|
||||||
_ORDINAL_WORDS = {
|
_ORDINAL_WORDS = {
|
||||||
"first": 1, "second": 2, "third": 3, "fourth": 4, "fifth": 5, "sixth": 6,
|
"first": 1,
|
||||||
"seventh": 7, "eighth": 8, "ninth": 9, "tenth": 10, "eleventh": 11, "twelfth": 12,
|
"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.
|
# 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 = _EDITION.sub(" ", title)
|
||||||
stripped = re.sub(r"\s{2,}", " ", stripped)
|
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(" ,;:-–—/")
|
stripped = stripped.strip(" ,;:-–—/")
|
||||||
|
|
||||||
# A title that is only an edition statement is not improved by having none.
|
# A title that is only an edition statement is not improved by having none.
|
||||||
@@ -178,7 +190,9 @@ class FileExtractor(Protocol):
|
|||||||
) -> dict[str, Any]: ...
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
@classmethod
|
@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:
|
class Extractor:
|
||||||
@@ -187,7 +201,9 @@ class Extractor:
|
|||||||
format_priorities = {"epub": 1, "pdf": 2}
|
format_priorities = {"epub": 1, "pdf": 2}
|
||||||
|
|
||||||
@classmethod
|
@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 = {}
|
metadata = {}
|
||||||
|
|
||||||
# Sort based on file priority
|
# Sort based on file priority
|
||||||
@@ -235,7 +251,7 @@ class Extractor:
|
|||||||
metadata = FilepathExtractor.extract_metadata(files[0], root_path) | metadata
|
metadata = FilepathExtractor.extract_metadata(files[0], root_path) | metadata
|
||||||
|
|
||||||
# format the title
|
# format the title
|
||||||
if metadata.get('title', None):
|
if metadata.get("title", None):
|
||||||
# Before the subtitle split, so the edition cannot be mistaken for one:
|
# Before the subtitle split, so the edition cannot be mistaken for one:
|
||||||
# "How Linux Works, 3rd Edition: What Every Superuser Should Know" has to
|
# "How Linux Works, 3rd Edition: What Every Superuser Should Know" has to
|
||||||
# lose the edition first for the colon count to mean anything.
|
# lose the edition first for the colon count to mean anything.
|
||||||
@@ -282,8 +298,8 @@ class Extractor:
|
|||||||
file_ext = get_file_extension(filename)
|
file_ext = get_file_extension(filename)
|
||||||
|
|
||||||
if file_ext is None:
|
if file_ext is None:
|
||||||
return float('inf')
|
return float("inf")
|
||||||
|
|
||||||
return Extractor.format_priorities.get(file_ext, float("inf"))
|
return Extractor.format_priorities.get(file_ext, float("inf"))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -618,8 +634,10 @@ class FilepathExtractor(FileExtractor):
|
|||||||
"""Extracts metadata from the filepath."""
|
"""Extracts metadata from the filepath."""
|
||||||
|
|
||||||
@classmethod
|
@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):
|
if isinstance(input, UploadFile):
|
||||||
path = Path(input.filename).parent
|
path = Path(input.filename).parent
|
||||||
else:
|
else:
|
||||||
@@ -627,33 +645,34 @@ class FilepathExtractor(FileExtractor):
|
|||||||
|
|
||||||
if root_path:
|
if root_path:
|
||||||
path = path.relative_to(root_path)
|
path = path.relative_to(root_path)
|
||||||
|
|
||||||
parts = path.parts
|
parts = path.parts
|
||||||
metadata: dict[str, str | None] = {}
|
metadata: dict[str, str | None] = {}
|
||||||
|
|
||||||
if len(parts) == 3:
|
if len(parts) == 3:
|
||||||
# Format: Author/Series/Part - Title/filename
|
# Format: Author/Series/Part - Title/filename
|
||||||
metadata['author'] = parts[0]
|
metadata["author"] = parts[0]
|
||||||
|
|
||||||
# Extract part number and title from directory name (parts[2])
|
# Extract part number and title from directory name (parts[2])
|
||||||
dirname = parts[2]
|
dirname = parts[2]
|
||||||
match = re.match(r'^([\d.]+)\s*-\s*(.+)$', dirname)
|
match = re.match(r"^([\d.]+)\s*-\s*(.+)$", dirname)
|
||||||
|
|
||||||
if match:
|
if match:
|
||||||
metadata['series_position'] = match.group(1) # Keep as string
|
metadata["series_position"] = match.group(1) # Keep as string
|
||||||
metadata['series'] = parts[1]
|
metadata["series"] = parts[1]
|
||||||
metadata['title'] = match.group(2).strip()
|
metadata["title"] = match.group(2).strip()
|
||||||
else:
|
else:
|
||||||
metadata['series'] = parts[1]
|
metadata["series"] = parts[1]
|
||||||
metadata['title'] = path.stem
|
metadata["title"] = path.stem
|
||||||
|
|
||||||
elif len(parts) == 2:
|
elif len(parts) == 2:
|
||||||
# Format: Author/Title
|
# Format: Author/Title
|
||||||
metadata['author'] = parts[0]
|
metadata["author"] = parts[0]
|
||||||
metadata['title'] = path.stem # Remove extension
|
metadata["title"] = path.stem # Remove extension
|
||||||
|
|
||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
class FilenameExtractor(FileExtractor):
|
class FilenameExtractor(FileExtractor):
|
||||||
"""Extracts metadata from the filename."""
|
"""Extracts metadata from the filename."""
|
||||||
|
|
||||||
|
|||||||
@@ -4,43 +4,57 @@ from typing import Any, Literal, Optional, Sequence
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
class LinkTypes(StrEnum):
|
class LinkTypes(StrEnum):
|
||||||
NAVIGATION = "application/atom+xml;profile=opds-catalog;kind=navigation"
|
NAVIGATION = "application/atom+xml;profile=opds-catalog;kind=navigation"
|
||||||
ACQUISITION = "application/atom+xml;profile=opds-catalog;kind=acquisition"
|
ACQUISITION = "application/atom+xml;profile=opds-catalog;kind=acquisition"
|
||||||
OPEN_SEARCH = "application/opensearchdescription+xml"
|
OPEN_SEARCH = "application/opensearchdescription+xml"
|
||||||
|
|
||||||
|
|
||||||
class AcquisitionRelations(StrEnum):
|
class AcquisitionRelations(StrEnum):
|
||||||
_BASE = "http://opds-spec.org/acquisition"
|
_BASE = "http://opds-spec.org/acquisition"
|
||||||
|
|
||||||
ACQUISITION = _BASE # A generic relation that indicates that the entry may be retrieved
|
ACQUISITION = (
|
||||||
OPEN_ACCESS = f"{_BASE}/open-access" # Entry may be retrieved without any requirement
|
_BASE # A generic relation that indicates that the entry may be retrieved
|
||||||
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
|
OPEN_ACCESS = (
|
||||||
SAMPLE = f"{_BASE}/sample" # Subset of the entry may be retrieved
|
f"{_BASE}/open-access" # Entry may be retrieved without any requirement
|
||||||
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
|
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):
|
class NavigationRelations(StrEnum):
|
||||||
_BASE = ""
|
_BASE = ""
|
||||||
|
|
||||||
|
|
||||||
class LinkRelations(StrEnum):
|
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
|
https://specs.opds.io/opds-1.2.html#6-additional-link-relations
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_BASE = "http://opds-spec.org"
|
_BASE = "http://opds-spec.org"
|
||||||
|
|
||||||
START = "start" # The OPDS catalog root
|
START = "start" # The OPDS catalog root
|
||||||
SUBSECTION = "subsection" # an OPDS feed not better described by any of the below relations
|
SUBSECTION = (
|
||||||
SHELF = f"{_BASE}/shelf" # Entries acquired by the euser
|
"subsection" # an OPDS feed not better described by any of the below relations
|
||||||
SUBSCRIPTIONS = f"{_BASE}/subscriptions" # Entries available with users's subscription
|
)
|
||||||
NEW = f"{_BASE}/sort/new" # Newest entries
|
SHELF = f"{_BASE}/shelf" # Entries acquired by the euser
|
||||||
POPULAR = f"{_BASE}/sort/popular" # Most popular entries
|
SUBSCRIPTIONS = (
|
||||||
FEATURED = f"{_BASE}/featured" # Entries selected for promotion
|
f"{_BASE}/subscriptions" # Entries available with users's subscription
|
||||||
RECOMMENDED = f"{_BASE}/recommended" # Entries recommended to the specific user
|
)
|
||||||
|
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
|
class Feed(BaseModel): # OPDS Catalog root element
|
||||||
@@ -117,9 +131,8 @@ class AcquisitionFeedLink(Link):
|
|||||||
|
|
||||||
|
|
||||||
class NavigationFeedLink(Link):
|
class NavigationFeedLink(Link):
|
||||||
type: str = Field(
|
type: str = Field(default=LinkTypes.NAVIGATION, serialization_alias="@type")
|
||||||
default=LinkTypes.NAVIGATION, serialization_alias="@type"
|
|
||||||
)
|
|
||||||
|
|
||||||
class Content(BaseModel):
|
class Content(BaseModel):
|
||||||
type: Literal["text"] = Field(default="text", serialization_alias="@type")
|
type: Literal["text"] = Field(default="text", serialization_alias="@type")
|
||||||
@@ -170,9 +183,10 @@ class Entry(BaseModel):
|
|||||||
data = super().model_dump(**kwargs)
|
data = super().model_dump(**kwargs)
|
||||||
return {"entry": data}
|
return {"entry": data}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PaginationResult:
|
class PaginationResult:
|
||||||
next_link: Optional[Link]
|
next_link: Optional[Link]
|
||||||
prev_link: Optional[Link]
|
prev_link: Optional[Link]
|
||||||
current_offset: int
|
current_offset: int
|
||||||
total_count: int
|
total_count: int
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
from typing import Any, Callable, Sequence
|
from typing import Any, Callable, Sequence
|
||||||
from urllib.parse import quote_plus, urlencode
|
from urllib.parse import quote_plus, urlencode
|
||||||
from litestar import Request
|
from litestar import Request
|
||||||
@@ -16,9 +15,10 @@ from .models import (
|
|||||||
AcquisitionFeedLink,
|
AcquisitionFeedLink,
|
||||||
NavigationFeed,
|
NavigationFeed,
|
||||||
NavigationFeedLink,
|
NavigationFeedLink,
|
||||||
PaginationResult
|
PaginationResult,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_opensearch_document(base_url: str = "/opds/search?") -> str:
|
def get_opensearch_document(base_url: str = "/opds/search?") -> str:
|
||||||
search = {
|
search = {
|
||||||
"OpenSearchDescription": {
|
"OpenSearchDescription": {
|
||||||
@@ -120,19 +120,20 @@ def create_navigation_feed(
|
|||||||
pretty=True,
|
pretty=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_library_navigation_feed(library: m.Library) -> str:
|
def create_library_navigation_feed(library: m.Library) -> str:
|
||||||
|
|
||||||
entries = [
|
entries = [
|
||||||
Entry(
|
Entry(
|
||||||
id=f"/opds/library/{library.id}/all-books",
|
id=f"/opds/library/{library.id}/all-books",
|
||||||
title='All Books',
|
title="All Books",
|
||||||
link=[
|
link=[
|
||||||
AcquisitionFeedLink(
|
AcquisitionFeedLink(
|
||||||
rel="subsection",
|
rel="subsection",
|
||||||
href=f"/opds/acquisition?libraries={library.id}&paginated=1&pageSize=50&feed_title=AllBooks&feed_id=/opds/library/{library.id}/all-books",
|
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",
|
title="All Books",
|
||||||
)
|
)
|
||||||
]
|
],
|
||||||
),
|
),
|
||||||
Entry(
|
Entry(
|
||||||
id=f"/opds/library/{library.id}/recently-added",
|
id=f"/opds/library/{library.id}/recently-added",
|
||||||
@@ -141,9 +142,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
|
|||||||
NavigationFeedLink(
|
NavigationFeedLink(
|
||||||
rel="http://opds-spec.org/sort/new",
|
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",
|
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(
|
Entry(
|
||||||
id=f"/opds/library/{library.id}/shelves",
|
id=f"/opds/library/{library.id}/shelves",
|
||||||
@@ -152,9 +153,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
|
|||||||
NavigationFeedLink(
|
NavigationFeedLink(
|
||||||
rel="subsection",
|
rel="subsection",
|
||||||
href=f"/opds/library/{library.id}/shelves?paginated=1&pageSize=10",
|
href=f"/opds/library/{library.id}/shelves?paginated=1&pageSize=10",
|
||||||
title="Bookshelves"
|
title="Bookshelves",
|
||||||
)
|
)
|
||||||
]
|
],
|
||||||
),
|
),
|
||||||
Entry(
|
Entry(
|
||||||
id=f"/opds/library/{library.id}/tags",
|
id=f"/opds/library/{library.id}/tags",
|
||||||
@@ -163,9 +164,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
|
|||||||
NavigationFeedLink(
|
NavigationFeedLink(
|
||||||
rel="subsection",
|
rel="subsection",
|
||||||
href=f"/opds/library/{library.id}/tags?paginated=1&pageSize=10",
|
href=f"/opds/library/{library.id}/tags?paginated=1&pageSize=10",
|
||||||
title="Tags"
|
title="Tags",
|
||||||
)
|
)
|
||||||
]
|
],
|
||||||
),
|
),
|
||||||
Entry(
|
Entry(
|
||||||
id=f"/opds/library/{library.id}/authors",
|
id=f"/opds/library/{library.id}/authors",
|
||||||
@@ -174,9 +175,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
|
|||||||
NavigationFeedLink(
|
NavigationFeedLink(
|
||||||
rel="subsection",
|
rel="subsection",
|
||||||
href=f"/opds/library/{library.id}/authors?paginated=1&pageSize=10",
|
href=f"/opds/library/{library.id}/authors?paginated=1&pageSize=10",
|
||||||
title="Authors"
|
title="Authors",
|
||||||
)
|
)
|
||||||
]
|
],
|
||||||
),
|
),
|
||||||
Entry(
|
Entry(
|
||||||
id=f"/opds/library/{library.id}/publishers",
|
id=f"/opds/library/{library.id}/publishers",
|
||||||
@@ -185,34 +186,34 @@ def create_library_navigation_feed(library: m.Library) -> str:
|
|||||||
NavigationFeedLink(
|
NavigationFeedLink(
|
||||||
rel="subsection",
|
rel="subsection",
|
||||||
href=f"/opds/library/{library.id}/publishers?paginated=1&pageSize=10",
|
href=f"/opds/library/{library.id}/publishers?paginated=1&pageSize=10",
|
||||||
title="Publishers"
|
title="Publishers",
|
||||||
)
|
)
|
||||||
]
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
feed = create_navigation_feed(
|
feed = create_navigation_feed(
|
||||||
id=f'/library/{library.id}',
|
id=f"/library/{library.id}",
|
||||||
title=library.name,
|
title=library.name,
|
||||||
self_url=f'/opds/library/{library.id}',
|
self_url=f"/opds/library/{library.id}",
|
||||||
links=[
|
links=[],
|
||||||
|
entries=entries,
|
||||||
],
|
|
||||||
entries=entries
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return feed
|
return feed
|
||||||
|
|
||||||
|
|
||||||
def create_collection_navigation_feed(
|
def create_collection_navigation_feed(
|
||||||
library: m.Library,
|
library: m.Library,
|
||||||
collection_type: str,
|
collection_type: str,
|
||||||
items: Sequence[m.BookList | m.Tag | m.Author | m.Publisher | m.BookSeries],
|
items: Sequence[m.BookList | m.Tag | m.Author | m.Publisher | m.BookSeries],
|
||||||
links: list[Link] = list(),
|
links: list[Link] = list(),
|
||||||
# Title is usually derived from the model's name or title
|
# 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:
|
) -> str:
|
||||||
|
|
||||||
entries = [
|
entries = [
|
||||||
Entry(
|
Entry(
|
||||||
id=f"/opds/library/{library.id}/{collection_type}/{item.id}",
|
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",
|
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),
|
title=get_title(item),
|
||||||
)
|
)
|
||||||
]
|
],
|
||||||
) for item in items
|
)
|
||||||
|
for item in items
|
||||||
]
|
]
|
||||||
|
|
||||||
return create_navigation_feed(
|
return create_navigation_feed(
|
||||||
id=f"/opds/library/{library.id}/{collection_type}",
|
id=f"/opds/library/{library.id}/{collection_type}",
|
||||||
title=collection_type.title(),
|
title=collection_type.title(),
|
||||||
self_url=f'/opds/library/{library.id}/{collection_type}',
|
self_url=f"/opds/library/{library.id}/{collection_type}",
|
||||||
entries=entries,
|
entries=entries,
|
||||||
links=links
|
links=links,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_next_paginated_link(
|
def create_next_paginated_link(
|
||||||
request: Request,
|
request: Request, total: int, current_count: int, offset: int, feed_title: str
|
||||||
total: int,
|
) -> Link | None:
|
||||||
current_count: int,
|
if total <= current_count + offset:
|
||||||
offset: int,
|
return None
|
||||||
feed_title: str
|
|
||||||
) -> Link | None:
|
params = dict(request.query_params)
|
||||||
if total <= current_count + offset:
|
params["currentPage"] = params.get("currentPage", 1) + 1
|
||||||
return None
|
|
||||||
|
next_url = f"{request.url.path}?{urlencode(list(params.items()), doseq=True)}"
|
||||||
params = dict(request.query_params)
|
|
||||||
params['currentPage'] = params.get('currentPage', 1) + 1
|
return Link(rel="next", href=next_url, title=feed_title, type=LinkTypes.NAVIGATION)
|
||||||
|
|
||||||
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(
|
def create_search_link(
|
||||||
request: Request,
|
request: Request, exclude_params: set[str] | None = None
|
||||||
exclude_params: set[str] | None = None
|
|
||||||
) -> Link:
|
) -> Link:
|
||||||
"""Create search link with current filters applied"""
|
"""Create search link with current filters applied"""
|
||||||
if exclude_params is None:
|
if exclude_params is None:
|
||||||
exclude_params = {'currentPage', 'feed_title', 'feed_id', 'search', 'paginated'}
|
exclude_params = {"currentPage", "feed_title", "feed_id", "search", "paginated"}
|
||||||
|
|
||||||
params = {
|
params = {k: v for k, v in request.query_params.items() if k not in exclude_params}
|
||||||
k: v for k, v in request.query_params.items()
|
|
||||||
if k not in exclude_params
|
|
||||||
}
|
|
||||||
|
|
||||||
return Link(
|
return Link(
|
||||||
rel="search",
|
rel="search",
|
||||||
href=f"/opds/opensearch?{urlencode(list(params.items()), doseq=True)}",
|
href=f"/opds/opensearch?{urlencode(list(params.items()), doseq=True)}",
|
||||||
@@ -277,7 +268,6 @@ def create_search_link(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def create_pagination_links(
|
def create_pagination_links(
|
||||||
request: Request,
|
request: Request,
|
||||||
total: int,
|
total: int,
|
||||||
@@ -287,38 +277,35 @@ def create_pagination_links(
|
|||||||
link_type: str = LinkTypes.ACQUISITION,
|
link_type: str = LinkTypes.ACQUISITION,
|
||||||
) -> PaginationResult:
|
) -> PaginationResult:
|
||||||
"""Create next/prev pagination links using limit/offset"""
|
"""Create next/prev pagination links using limit/offset"""
|
||||||
|
|
||||||
next_link = None
|
next_link = None
|
||||||
prev_link = None
|
prev_link = None
|
||||||
|
|
||||||
# Create next link if there are more items
|
# Create next link if there are more items
|
||||||
if offset + limit < total:
|
if offset + limit < total:
|
||||||
params = dict(request.query_params)
|
params = dict(request.query_params)
|
||||||
# Calculate next page number
|
# Calculate next page number
|
||||||
current_page = (offset // limit) + 1
|
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_url = f"{request.url.path}?{urlencode(params, doseq=True)}"
|
||||||
next_link = Link(
|
next_link = Link(
|
||||||
rel="next",
|
rel="next", href=next_url, title=f"{feed_title} - Next", type=link_type
|
||||||
href=next_url,
|
|
||||||
title=f"{feed_title} - Next",
|
|
||||||
type=link_type
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create previous link if not on first page
|
# Create previous link if not on first page
|
||||||
if offset > 0:
|
if offset > 0:
|
||||||
params = dict(request.query_params)
|
params = dict(request.query_params)
|
||||||
# Calculate previous page number
|
# Calculate previous page number
|
||||||
current_page = (offset // limit) + 1
|
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_url = f"{request.url.path}?{urlencode(params, doseq=True)}"
|
||||||
prev_link = Link(
|
prev_link = Link(
|
||||||
rel="previous",
|
rel="previous",
|
||||||
href=prev_url,
|
href=prev_url,
|
||||||
title=f"{feed_title} - Previous",
|
title=f"{feed_title} - Previous",
|
||||||
type=link_type
|
type=link_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
return PaginationResult(next_link, prev_link, offset, total)
|
return PaginationResult(next_link, prev_link, offset, total)
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ async def create_directory(dir_path: Path | str) -> None:
|
|||||||
|
|
||||||
await aios.makedirs(dir_path, exist_ok=True)
|
await aios.makedirs(dir_path, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
async def move_file(src_path: Path, dest_path: Path, create_dirs=True) -> None:
|
async def move_file(src_path: Path, dest_path: Path, create_dirs=True) -> None:
|
||||||
"""
|
"""
|
||||||
Move a file from source to destination asynchronously.
|
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.
|
# all configured separately, so they can easily be separate mounts.
|
||||||
shutil.move(str(src_path), str(dest_path))
|
shutil.move(str(src_path), str(dest_path))
|
||||||
|
|
||||||
|
|
||||||
async def copy_file(src_path: Path, dest_path: Path, create_dirs: bool = True) -> None:
|
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.
|
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):
|
elif isinstance(file, UploadFile):
|
||||||
return Path(file.filename).suffix.lower()[1:]
|
return Path(file.filename).suffix.lower()[1:]
|
||||||
|
|
||||||
raise ValueError("file object type is not supported")
|
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)
|
filename = Path(file.name)
|
||||||
else:
|
else:
|
||||||
raise ValueError("file object type is not supported")
|
raise ValueError("file object type is not supported")
|
||||||
|
|
||||||
if ext:
|
if ext:
|
||||||
return str(filename)
|
return str(filename)
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ services:
|
|||||||
|
|
||||||
|
|
||||||
backend:
|
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
|
build: ./backend
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
@@ -53,6 +57,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
image: git.jaroszew.ski/patrick/chitai-frontend:${CHITAI_VERSION:-latest}
|
||||||
|
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
@@ -66,6 +72,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
VITE_BACKEND_API_URL: ${CHITAI_API_URL}
|
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:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -8,9 +8,14 @@ bun.lockb
|
|||||||
# Ignore artifacts:
|
# Ignore artifacts:
|
||||||
build
|
build
|
||||||
coverage
|
coverage
|
||||||
|
.pytest_cache
|
||||||
|
|
||||||
# Miscellaneous
|
# Miscellaneous
|
||||||
/static/
|
/static/
|
||||||
|
|
||||||
# Vendored third-party source, copied verbatim by scripts/vendor-foliate.sh
|
# Vendored third-party source, copied verbatim by scripts/vendor-foliate.sh
|
||||||
/src/lib/vendor/
|
/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
@@ -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
|
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`
|
`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.
|
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
|
- **Prettier is clean and CI blocks on it** — run `pnpm format` before finishing. Two things it
|
||||||
`svelte/no-navigation-without-resolve` on plain `href`s, plus ~59 files Prettier would rewrite
|
must not touch are in `.prettierignore`: the vendored foliate-js, and
|
||||||
(mostly vendored shadcn components). Check the files you touched, not the whole tree.
|
`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
|
- `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
|
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**
|
`arrayBuffer()` and forwards no `Range` header, so book downloads are not streamed. **Requests**
|
||||||
|
|||||||
+7
-2
@@ -11,7 +11,9 @@ WORKDIR /app
|
|||||||
|
|
||||||
FROM base AS prod-deps
|
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 \
|
RUN --mount=type=cache,target=/pnpm/store \
|
||||||
pnpm fetch --frozen-lockfile
|
pnpm fetch --frozen-lockfile
|
||||||
@@ -25,7 +27,7 @@ RUN --mount=type=cache,target=/pnpm/store \
|
|||||||
|
|
||||||
FROM base AS build
|
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 \
|
RUN --mount=type=cache,target=/pnpm/store \
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
@@ -54,4 +56,7 @@ EXPOSE 3000
|
|||||||
|
|
||||||
WORKDIR /app
|
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" ]
|
CMD [ "node", "build" ]
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
|||||||
export default defineConfig(
|
export default defineConfig(
|
||||||
includeIgnoreFile(gitignorePath),
|
includeIgnoreFile(gitignorePath),
|
||||||
// Vendored third-party source. Tracked, so .gitignore does not cover it.
|
// 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,
|
js.configs.recommended,
|
||||||
...ts.configs.recommended,
|
...ts.configs.recommended,
|
||||||
...svelte.configs.recommended,
|
...svelte.configs.recommended,
|
||||||
@@ -35,6 +37,13 @@ export default defineConfig(
|
|||||||
files: ['src/lib/components/ui/**'],
|
files: ['src/lib/components/ui/**'],
|
||||||
rules: { 'svelte/no-navigation-without-resolve': 'off' }
|
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'],
|
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
|
|||||||
+23
-22
@@ -2,6 +2,7 @@
|
|||||||
"name": "chitai-web",
|
"name": "chitai-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
|
"packageManager": "pnpm@11.20.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -14,42 +15,42 @@
|
|||||||
"lint": "prettier --check . && eslint ."
|
"lint": "prettier --check . && eslint ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.4.1",
|
"@eslint/compat": "^2.1.0",
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^10.0.1",
|
||||||
"@iconify/svelte": "^5.2.1",
|
"@iconify/svelte": "^5.2.2",
|
||||||
"@internationalized/date": "^3.12.0",
|
"@internationalized/date": "^3.12.3",
|
||||||
"@lucide/svelte": "^0.544.0",
|
"@lucide/svelte": "^0.544.0",
|
||||||
"@sveltejs/adapter-node": "^5.5.4",
|
"@sveltejs/adapter-node": "^5.5.7",
|
||||||
"@sveltejs/kit": "^2.53.4",
|
"@sveltejs/kit": "^2.70.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@types/node": "^22.19.15",
|
"@types/node": "^26.2.0",
|
||||||
"bits-ui": "^2.16.3",
|
"bits-ui": "^2.18.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^10.8.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-svelte": "^3.15.0",
|
"eslint-plugin-svelte": "^3.23.0",
|
||||||
"globals": "^16.5.0",
|
"globals": "^17.11.0",
|
||||||
"jsrepo": "^2.5.2",
|
"jsrepo": "^2.5.2",
|
||||||
"openapi-typescript": "^7.13.0",
|
"openapi-typescript": "^7.13.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.9.6",
|
||||||
"prettier-plugin-svelte": "^3.5.2",
|
"prettier-plugin-svelte": "^3.5.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||||
"svelte": "^5.53.7",
|
"svelte": "^5.56.9",
|
||||||
"svelte-check": "^4.4.5",
|
"svelte-check": "^4.7.6",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwind-scrollbar": "^4.0.2",
|
"tailwind-scrollbar": "^4.0.2",
|
||||||
"tailwind-variants": "^3.2.2",
|
"tailwind-variants": "^3.3.1",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.3.3",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.56.1",
|
"typescript-eslint": "^8.67.0",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.6"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"construct-style-sheets-polyfill": "^3.1.0",
|
"construct-style-sheets-polyfill": "^3.1.0",
|
||||||
"mode-watcher": "^1.1.0",
|
"mode-watcher": "^1.1.0",
|
||||||
"svelte-sonner": "^1.0.8",
|
"svelte-sonner": "^1.2.1",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.4.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1187
-1225
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,8 @@
|
|||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
|
|
||||||
/* Typography — system stacks, so nothing depends on a CDN or a webfont build. */
|
/* 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-serif: Georgia, 'Iowan Old Style', 'Times New Roman', serif;
|
||||||
--app-font-mono: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace;
|
--app-font-mono: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { command, getRequestEvent, query } from '$app/server';
|
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 { createQueryParams } from '$lib/utils';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
@@ -19,46 +24,51 @@ export const listBookshelves = query(bookshelfQuerySchema, async (data) => {
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
});
|
});
|
||||||
|
|
||||||
export const addBooksToShelf = command(modifyBooksInShelf, async ({ shelf_id, ...data }): Promise<Bookshelf> => {
|
export const addBooksToShelf = command(
|
||||||
const { locals } = getRequestEvent();
|
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) {
|
if (!response.ok) {
|
||||||
const message = await response.text();
|
const message = await response.text();
|
||||||
error(response.status, message);
|
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 params = createQueryParams(data);
|
||||||
const { locals } = getRequestEvent();
|
|
||||||
|
|
||||||
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) {
|
return await response.json();
|
||||||
const message = await response.text();
|
|
||||||
error(response.status, message);
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
return await response.json()
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
export const createBookshelf = command(bookshelfCreate, async (data) => {
|
export const createBookshelf = command(bookshelfCreate, async (data) => {
|
||||||
const { locals } = getRequestEvent();
|
const { locals } = getRequestEvent();
|
||||||
|
|
||||||
const response = await locals.api.post(`/shelves`, data)
|
const response = await locals.api.post(`/shelves`, data);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const message = await response.text();
|
const message = await response.text();
|
||||||
error(response.status, message);
|
error(response.status, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json()
|
return await response.json();
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -4,40 +4,40 @@ import { error } from '@sveltejs/kit';
|
|||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
export const listDevices = query(async (): Promise<Device[]> => {
|
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();
|
const deviceResult = await response.json();
|
||||||
return deviceResult.items
|
return deviceResult.items;
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createDevice = form(createDeviceSchema, async (data): Promise<Device> => {
|
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> => {
|
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> => {
|
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>
|
</script>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
{...createDevice.enhance(async ({ form, submit }) => {
|
{...createDevice.enhance(async ({ element, submit }) => {
|
||||||
try {
|
try {
|
||||||
await submit();
|
await submit();
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
const deviceName = createDevice.fields.name.value();
|
const deviceName = createDevice.fields.name.value();
|
||||||
|
|
||||||
form.reset();
|
element.reset();
|
||||||
toast.success(`Device '${deviceName}' created`);
|
toast.success(`Device '${deviceName}' created`);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -50,10 +50,10 @@
|
|||||||
|
|
||||||
<form
|
<form
|
||||||
bind:this={formEl}
|
bind:this={formEl}
|
||||||
{...updateBookCover.enhance(async ({ submit, form }) => {
|
{...updateBookCover.enhance(async ({ submit, element }) => {
|
||||||
try {
|
try {
|
||||||
await submit();
|
await submit();
|
||||||
form.reset();
|
element.reset();
|
||||||
// Deliberately does not close the dialog. The cover is one panel of a
|
// Deliberately does not close the dialog. The cover is one panel of a
|
||||||
// larger form now, and closing here would throw away metadata edits
|
// larger form now, and closing here would throw away metadata edits
|
||||||
// the reader has not saved yet.
|
// the reader has not saved yet.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
{...createLibrary.enhance(async ({ form, submit }) => {
|
{...createLibrary.enhance(async ({ element, submit }) => {
|
||||||
try {
|
try {
|
||||||
await submit();
|
await submit();
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
const libraryName = createLibrary.fields.name.value();
|
const libraryName = createLibrary.fields.name.value();
|
||||||
|
|
||||||
form.reset();
|
element.reset();
|
||||||
selectedIcon = 'library';
|
selectedIcon = 'library';
|
||||||
open = false;
|
open = false;
|
||||||
toast.success(`Library '${libraryName}' created.`);
|
toast.success(`Library '${libraryName}' created.`);
|
||||||
|
|||||||
@@ -1,35 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||||
import { Button } from "$lib/components/ui/button/index.js";
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import { Input } from "$lib/components/ui/input/index.js";
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
import { Label } from "$lib/components/ui/label/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('')
|
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(),
|
||||||
|
onSubmit
|
||||||
|
}: { open?: boolean; onSubmit: (name: string) => Promise<undefined> } = $props();
|
||||||
|
let shelfName = $state('');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<Dialog.Root bind:open>
|
<Dialog.Root bind:open>
|
||||||
<Dialog.Content class="sm:max-w-[425px]">
|
<Dialog.Content class="sm:max-w-[425px]">
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>Create bookshelf</Dialog.Title>
|
<Dialog.Title>Create bookshelf</Dialog.Title>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<Label>Name</Label>
|
<Label>Name</Label>
|
||||||
<Input bind:value={shelfName}/>
|
<Input bind:value={shelfName} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Buttons -->
|
<!-- Buttons -->
|
||||||
<Dialog.Footer class="ml-auto flex">
|
<Dialog.Footer class="ml-auto flex">
|
||||||
<Button variant="outline" onclick={() => (open = false)}>Cancel</Button>
|
<Button variant="outline" onclick={() => (open = false)}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
onclick={async () => {
|
onclick={async () => {
|
||||||
await onSubmit(shelfName);
|
await onSubmit(shelfName);
|
||||||
}}
|
}}
|
||||||
variant="default">Create</Button
|
variant="default">Create</Button
|
||||||
>
|
>
|
||||||
</Dialog.Footer>
|
</Dialog.Footer>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
</Card.Header>
|
</Card.Header>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
{...signup.enhance(async ({ submit, form }) => {
|
{...signup.enhance(async ({ submit, element }) => {
|
||||||
try {
|
try {
|
||||||
await submit();
|
await submit();
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
// Move to login tab on success
|
// Move to login tab on success
|
||||||
// TODO: Fix previous errors showing on login form
|
// TODO: Fix previous errors showing on login form
|
||||||
form.reset();
|
element.reset();
|
||||||
toast.success('Successfully registered!');
|
toast.success('Successfully registered!');
|
||||||
login.fields.set({ email: '', password: '' });
|
login.fields.set({ email: '', password: '' });
|
||||||
login.validate();
|
login.validate();
|
||||||
|
|||||||
@@ -28,7 +28,9 @@
|
|||||||
// directly in the markup keeps it static.
|
// directly in the markup keeps it static.
|
||||||
const header = $derived({
|
const header = $derived({
|
||||||
title: 'chitai',
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||||
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
|
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
|
||||||
import { Badge } from "$lib/components/ui/badge/index.js";
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
import { getLibraryState, LibraryState } from '$lib/state/library.svelte';
|
import { getLibraryState, LibraryState } from '$lib/state/library.svelte';
|
||||||
import { LIBRARY_ICONS } from '$lib/components/ui/icon-picker/index.js';
|
import { LIBRARY_ICONS } from '$lib/components/ui/icon-picker/index.js';
|
||||||
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
|
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
|
||||||
@@ -12,7 +12,9 @@
|
|||||||
const libraryState = getLibraryState();
|
const libraryState = getLibraryState();
|
||||||
const sidebar = useSidebar();
|
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>
|
</script>
|
||||||
|
|
||||||
<Sidebar.Menu>
|
<Sidebar.Menu>
|
||||||
@@ -35,7 +37,7 @@
|
|||||||
<span class="ml-1 truncate font-semibold">
|
<span class="ml-1 truncate font-semibold">
|
||||||
{libraryState.activeLibrary!.name}
|
{libraryState.activeLibrary!.name}
|
||||||
</span>
|
</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
|
{libraryState.activeLibrary!.total ?? 0} books
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,15 +53,15 @@
|
|||||||
>
|
>
|
||||||
<DropdownMenu.Label class="text-xs text-muted-foreground">Libraries</DropdownMenu.Label>
|
<DropdownMenu.Label class="text-xs text-muted-foreground">Libraries</DropdownMenu.Label>
|
||||||
{#each libraryState.libraries as library (library.name)}
|
{#each libraryState.libraries as library (library.name)}
|
||||||
{@const LibraryIcon = (LIBRARY_ICONS[library.icon ?? 'library'] ?? LIBRARY_ICONS['library']).component}
|
{@const LibraryIcon = (
|
||||||
|
LIBRARY_ICONS[library.icon ?? 'library'] ?? LIBRARY_ICONS['library']
|
||||||
|
).component}
|
||||||
<DropdownMenu.Item onSelect={() => libraryState.setActive(library.id)} class="gap-2 p-2">
|
<DropdownMenu.Item onSelect={() => libraryState.setActive(library.id)} class="gap-2 p-2">
|
||||||
<div class="flex size-6 items-center justify-center rounded-md border">
|
<div class="flex size-6 items-center justify-center rounded-md border">
|
||||||
<LibraryIcon class="size-3.5 shrink-0" />
|
<LibraryIcon class="size-3.5 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
{library.name}
|
{library.name}
|
||||||
<Badge
|
<Badge variant="outline" class="ml-auto font-semibold">
|
||||||
variant="outline"
|
|
||||||
class="font-semibold ml-auto">
|
|
||||||
{library.total ?? 0}
|
{library.total ?? 0}
|
||||||
</Badge>
|
</Badge>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -14,10 +14,10 @@
|
|||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="avatar-badge"
|
data-slot="avatar-badge"
|
||||||
class={cn(
|
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",
|
'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=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=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",
|
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
import { Avatar as AvatarPrimitive } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
bind:ref
|
bind:ref
|
||||||
data-slot="avatar-fallback"
|
data-slot="avatar-fallback"
|
||||||
class={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="avatar-group-count"
|
data-slot="avatar-group-count"
|
||||||
class={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="avatar-group"
|
data-slot="avatar-group"
|
||||||
class={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
import { Avatar as AvatarPrimitive } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -12,6 +12,6 @@
|
|||||||
<AvatarPrimitive.Image
|
<AvatarPrimitive.Image
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="avatar-image"
|
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}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
import { Avatar as AvatarPrimitive } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
loadingStatus = $bindable("loading"),
|
loadingStatus = $bindable('loading'),
|
||||||
size = "default",
|
size = 'default',
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: AvatarPrimitive.RootProps & {
|
}: AvatarPrimitive.RootProps & {
|
||||||
size?: "default" | "sm" | "lg";
|
size?: 'default' | 'sm' | 'lg';
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
data-slot="avatar"
|
data-slot="avatar"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
class={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import Badge from "./avatar-badge.svelte";
|
import Badge from './avatar-badge.svelte';
|
||||||
import Fallback from "./avatar-fallback.svelte";
|
import Fallback from './avatar-fallback.svelte';
|
||||||
import GroupCount from "./avatar-group-count.svelte";
|
import GroupCount from './avatar-group-count.svelte';
|
||||||
import Group from "./avatar-group.svelte";
|
import Group from './avatar-group.svelte';
|
||||||
import Image from "./avatar-image.svelte";
|
import Image from './avatar-image.svelte';
|
||||||
import Root from "./avatar.svelte";
|
import Root from './avatar.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
@@ -18,5 +18,5 @@ export {
|
|||||||
Fallback as AvatarFallback,
|
Fallback as AvatarFallback,
|
||||||
Badge as AvatarBadge,
|
Badge as AvatarBadge,
|
||||||
Group as AvatarGroup,
|
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',
|
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',
|
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
accent:
|
accent: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90'
|
||||||
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90'
|
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
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';
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
export type FileRejectedReason =
|
export type FileRejectedReason =
|
||||||
| 'Maximum file size exceeded'
|
'Maximum file size exceeded' | 'File type not allowed' | 'Maximum files uploaded';
|
||||||
| 'File type not allowed'
|
|
||||||
| 'Maximum files uploaded';
|
|
||||||
|
|
||||||
export type FileDropZonePropsWithoutHTML = WithChildren<{
|
export type FileDropZonePropsWithoutHTML = WithChildren<{
|
||||||
ref?: HTMLInputElement | null;
|
ref?: HTMLInputElement | null;
|
||||||
|
|||||||
@@ -45,11 +45,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
<Popover.Content class="w-64 p-3" align="start">
|
<Popover.Content class="w-64 p-3" align="start">
|
||||||
<Input
|
<Input bind:value={search} placeholder="Search icons..." class="mb-3 h-8" />
|
||||||
bind:value={search}
|
|
||||||
placeholder="Search icons..."
|
|
||||||
class="mb-3 h-8"
|
|
||||||
/>
|
|
||||||
<ScrollArea class="h-48">
|
<ScrollArea class="h-48">
|
||||||
<div class="grid grid-cols-6 gap-1">
|
<div class="grid grid-cols-6 gap-1">
|
||||||
{#each filteredIcons as [key, icon] (key)}
|
{#each filteredIcons as [key, icon] (key)}
|
||||||
@@ -58,7 +54,10 @@
|
|||||||
<Tooltip.Trigger>
|
<Tooltip.Trigger>
|
||||||
<button
|
<button
|
||||||
type="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)}
|
onclick={() => selectIcon(key)}
|
||||||
>
|
>
|
||||||
<IconComponent class="size-4" />
|
<IconComponent class="size-4" />
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ import type { Component } from 'svelte';
|
|||||||
|
|
||||||
export type IconName = keyof typeof LIBRARY_ICONS;
|
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
|
// Generic
|
||||||
library: { component: Library, label: 'Library', category: 'Generic' },
|
library: { component: Library, label: 'Library', category: 'Generic' },
|
||||||
'book-open': { component: BookOpen, label: 'Book Open', category: 'Generic' },
|
'book-open': { component: BookOpen, label: 'Book Open', category: 'Generic' },
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import Root from "./popover.svelte";
|
import Root from './popover.svelte';
|
||||||
import Close from "./popover-close.svelte";
|
import Close from './popover-close.svelte';
|
||||||
import Content from "./popover-content.svelte";
|
import Content from './popover-content.svelte';
|
||||||
import Trigger from "./popover-trigger.svelte";
|
import Trigger from './popover-trigger.svelte';
|
||||||
import Portal from "./popover-portal.svelte";
|
import Portal from './popover-portal.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
@@ -15,5 +15,5 @@ export {
|
|||||||
Content as PopoverContent,
|
Content as PopoverContent,
|
||||||
Trigger as PopoverTrigger,
|
Trigger as PopoverTrigger,
|
||||||
Close as PopoverClose,
|
Close as PopoverClose,
|
||||||
Portal as PopoverPortal,
|
Portal as PopoverPortal
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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();
|
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
import PopoverPortal from "./popover-portal.svelte";
|
import PopoverPortal from './popover-portal.svelte';
|
||||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||||
import type { ComponentProps } from "svelte";
|
import type { ComponentProps } from 'svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
align = "center",
|
align = 'center',
|
||||||
portalProps,
|
portalProps,
|
||||||
...restProps
|
...restProps
|
||||||
}: PopoverPrimitive.ContentProps & {
|
}: PopoverPrimitive.ContentProps & {
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
{sideOffset}
|
{sideOffset}
|
||||||
{align}
|
{align}
|
||||||
class={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
|
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -12,6 +12,6 @@
|
|||||||
<PopoverPrimitive.Trigger
|
<PopoverPrimitive.Trigger
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="popover-trigger"
|
data-slot="popover-trigger"
|
||||||
class={cn("", className)}
|
class={cn('', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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();
|
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Tooltip as TooltipPrimitive } from "bits-ui";
|
import { Tooltip as TooltipPrimitive } from 'bits-ui';
|
||||||
import Trigger from "./tooltip-trigger.svelte";
|
import Trigger from './tooltip-trigger.svelte';
|
||||||
import Content from "./tooltip-content.svelte";
|
import Content from './tooltip-content.svelte';
|
||||||
|
|
||||||
const Root = TooltipPrimitive.Root;
|
const Root = TooltipPrimitive.Root;
|
||||||
const Provider = TooltipPrimitive.Provider;
|
const Provider = TooltipPrimitive.Provider;
|
||||||
@@ -17,5 +17,5 @@ export {
|
|||||||
Content as TooltipContent,
|
Content as TooltipContent,
|
||||||
Trigger as TooltipTrigger,
|
Trigger as TooltipTrigger,
|
||||||
Provider as TooltipProvider,
|
Provider as TooltipProvider,
|
||||||
Portal as TooltipPortal,
|
Portal as TooltipPortal
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Tooltip as TooltipPrimitive } from "bits-ui";
|
import { Tooltip as TooltipPrimitive } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
sideOffset = 0,
|
sideOffset = 0,
|
||||||
side = "top",
|
side = 'top',
|
||||||
children,
|
children,
|
||||||
arrowClasses,
|
arrowClasses,
|
||||||
...restProps
|
...restProps
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
{sideOffset}
|
{sideOffset}
|
||||||
{side}
|
{side}
|
||||||
class={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
@@ -32,11 +32,11 @@
|
|||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
"bg-primary z-50 size-2.5 rotate-45 rounded-[2px]",
|
'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=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=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=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2',
|
||||||
"data-[side=left]:-translate-y-[calc(50%_-_3px)]",
|
'data-[side=left]:-translate-y-[calc(50%_-_3px)]',
|
||||||
arrowClasses
|
arrowClasses
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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();
|
let { ref = $bindable(null), ...restProps }: TooltipPrimitive.TriggerProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -49,7 +49,8 @@
|
|||||||
Read
|
Read
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<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 class="size-4" />
|
||||||
Download
|
Download
|
||||||
|
|||||||
@@ -111,7 +111,11 @@
|
|||||||
<Spinner />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
{:else if bookCollection.books.length > 0}
|
{:else if bookCollection.books.length > 0}
|
||||||
<ScrollArea bind:viewportRef={scrollContainer} orientation="both" class="h-[calc(100vh-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'}
|
{#if bookCollection.view === 'grid'}
|
||||||
<BookGrid books={bookCollection.books} />
|
<BookGrid books={bookCollection.books} />
|
||||||
{:else if bookCollection.view === 'list'}
|
{:else if bookCollection.view === 'list'}
|
||||||
@@ -170,7 +174,7 @@
|
|||||||
</Sidebar.Inset>
|
</Sidebar.Inset>
|
||||||
|
|
||||||
<!-- Filter Sidebar (right-side) -->
|
<!-- 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>
|
</Sidebar.Provider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -73,8 +73,8 @@
|
|||||||
bookOps.deleteDialogTitle = `Delete "${book.title}"?`;
|
bookOps.deleteDialogTitle = `Delete "${book.title}"?`;
|
||||||
bookOps.deleteFn = async (deleteFiles: boolean) => {
|
bookOps.deleteFn = async (deleteFiles: boolean) => {
|
||||||
await bookOps.deleteBooks([book.id], deleteFiles);
|
await bookOps.deleteBooks([book.id], deleteFiles);
|
||||||
libraryState.activeLibrary!.total!--
|
libraryState.activeLibrary!.total!--;
|
||||||
bookshelfState.deletedBooks([book])
|
bookshelfState.deletedBooks([book]);
|
||||||
};
|
};
|
||||||
bookOps.deleteDialogOpen = true;
|
bookOps.deleteDialogOpen = true;
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -121,7 +121,10 @@
|
|||||||
? 'cursor-pointer'
|
? '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} />
|
<BookCover {book} height={110} />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
onclick={() => (active ? bookCollection.clearView() : bookCollection.applyPreset(preset))}
|
onclick={() => (active ? bookCollection.clearView() : bookCollection.applyPreset(preset))}
|
||||||
class="shrink-0 rounded-full border px-3 py-1 text-xs whitespace-nowrap transition-colors {active
|
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'}"
|
: 'border-border text-muted-foreground hover:border-muted-foreground hover:text-foreground'}"
|
||||||
>
|
>
|
||||||
{preset.label}
|
{preset.label}
|
||||||
|
|||||||
@@ -19,9 +19,7 @@
|
|||||||
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} relative"
|
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} relative"
|
||||||
>
|
>
|
||||||
{#if bookCollection.hasActiveSort}
|
{#if bookCollection.hasActiveSort}
|
||||||
<div
|
<div class="absolute top-[5px] right-[5px] h-1.5 w-1.5 rounded-full bg-flag"></div>
|
||||||
class="absolute top-[5px] right-[5px] h-1.5 w-1.5 rounded-full bg-flag"
|
|
||||||
></div>
|
|
||||||
{/if}
|
{/if}
|
||||||
<ArrowUpDown />
|
<ArrowUpDown />
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const bookshelfCreate = z.object({
|
|||||||
title: z.string().min(1, 'Must have a title'),
|
title: z.string().min(1, 'Must have a title'),
|
||||||
library_id: stringCoerce.optional(),
|
library_id: stringCoerce.optional(),
|
||||||
book_ids: stringArrayCoerce.optional()
|
book_ids: stringArrayCoerce.optional()
|
||||||
})
|
});
|
||||||
|
|
||||||
export type BookshelfQuerySchema = typeof bookshelfQuerySchema;
|
export type BookshelfQuerySchema = typeof bookshelfQuerySchema;
|
||||||
export type ModifyBooksInShelf = typeof modifyBooksInShelf;
|
export type ModifyBooksInShelf = typeof modifyBooksInShelf;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { components } from './openapi/schema';
|
import type { components } from './openapi/schema';
|
||||||
export type Device = components['schemas']['KosyncDeviceRead']
|
export type Device = components['schemas']['KosyncDeviceRead'];
|
||||||
|
|
||||||
export const createDeviceSchema = z.object({
|
export const createDeviceSchema = z.object({
|
||||||
name: z.string().min(1, 'Name cannot be empty')
|
name: z.string().min(1, 'Name cannot be empty')
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -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 { invalidate } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import {
|
import { deleteBookFiles, deleteBooks, listBooks, updateBookProgress } from '$lib/api';
|
||||||
deleteBookFiles,
|
|
||||||
deleteBooks,
|
|
||||||
listBooks,
|
|
||||||
updateBookProgress
|
|
||||||
} from '$lib/api';
|
|
||||||
import {
|
import {
|
||||||
type Book,
|
type Book,
|
||||||
type UpdateBookProgress,
|
type UpdateBookProgress,
|
||||||
type BookQuery,
|
type BookQuery,
|
||||||
type PaginatedResponse,
|
type PaginatedResponse
|
||||||
} from '$lib/schema';
|
} from '$lib/schema';
|
||||||
import { getContext, setContext } from 'svelte';
|
import { getContext, setContext } from 'svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|||||||
@@ -7,12 +7,9 @@ export class BookSelectionState {
|
|||||||
readonly selectionModeActive = $derived(this.selectedBooks.size !== 0);
|
readonly selectionModeActive = $derived(this.selectedBooks.size !== 0);
|
||||||
|
|
||||||
toggleSelection(book: Book) {
|
toggleSelection(book: Book) {
|
||||||
const bookId = book.id.toString()
|
const bookId = book.id.toString();
|
||||||
if (this.selectedBooks.has(bookId))
|
if (this.selectedBooks.has(bookId)) this.selectedBooks.delete(bookId);
|
||||||
this.selectedBooks.delete(bookId);
|
else this.selectedBooks.set(bookId, book);
|
||||||
|
|
||||||
else
|
|
||||||
this.selectedBooks.set(bookId, book);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isSelected(id: number | string): boolean {
|
isSelected(id: number | string): boolean {
|
||||||
@@ -20,11 +17,11 @@ export class BookSelectionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getSelectedBooks(): Book[] {
|
getSelectedBooks(): Book[] {
|
||||||
return Array.from(this.selectedBooks.values())
|
return Array.from(this.selectedBooks.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
getSelectedIds(): string[] {
|
getSelectedIds(): string[] {
|
||||||
return Array.from(this.selectedBooks.keys())
|
return Array.from(this.selectedBooks.keys());
|
||||||
}
|
}
|
||||||
|
|
||||||
numSelected() {
|
numSelected() {
|
||||||
@@ -33,9 +30,8 @@ export class BookSelectionState {
|
|||||||
|
|
||||||
selectAll(books: Book[]) {
|
selectAll(books: Book[]) {
|
||||||
books.forEach((book) => {
|
books.forEach((book) => {
|
||||||
const bookId = book.id.toString()
|
const bookId = book.id.toString();
|
||||||
if (!this.selectedBooks.has(bookId))
|
if (!this.selectedBooks.has(bookId)) this.selectedBooks.set(bookId, book);
|
||||||
this.selectedBooks.set(bookId, book)
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ export class BookshelfState {
|
|||||||
readonly libraryBookshelves = new SvelteMap<string, Bookshelf[]>();
|
readonly libraryBookshelves = new SvelteMap<string, Bookshelf[]>();
|
||||||
|
|
||||||
getBookshelves(libraryId: string | number): Bookshelf[] | undefined {
|
getBookshelves(libraryId: string | number): Bookshelf[] | undefined {
|
||||||
const id = libraryId.toString();
|
const id = libraryId.toString();
|
||||||
|
|
||||||
if (!this.libraryBookshelves.has(id)) {
|
if (!this.libraryBookshelves.has(id)) {
|
||||||
this.fetchBookshelves(id).then(shelves => {
|
this.fetchBookshelves(id).then((shelves) => {
|
||||||
this.libraryBookshelves.set(id, shelves);
|
this.libraryBookshelves.set(id, shelves);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.libraryBookshelves.get(id);
|
return this.libraryBookshelves.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchBookshelves(libraryId: string) {
|
async fetchBookshelves(libraryId: string) {
|
||||||
try {
|
try {
|
||||||
@@ -38,108 +38,109 @@ export class BookshelfState {
|
|||||||
title: name,
|
title: name,
|
||||||
library_id: libraryId,
|
library_id: libraryId,
|
||||||
book_ids: booksToAdd
|
book_ids: booksToAdd
|
||||||
})
|
});
|
||||||
|
|
||||||
if (bookshelf.library_id) {
|
if (bookshelf.library_id) {
|
||||||
const currentShelves = this.libraryBookshelves.get(bookshelf.library_id.toString()) || [];
|
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)
|
if (booksToAdd?.length) toast.success(`Added ${booksToAdd.length} books to '${name}'`);
|
||||||
toast.success(`Added ${booksToAdd.length} books to '${name}'`)
|
else toast.success(`Created shelf '${name}'`);
|
||||||
else
|
|
||||||
toast.success(`Created shelf '${name}'`)
|
|
||||||
|
|
||||||
return bookshelf
|
|
||||||
|
|
||||||
|
return bookshelf;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(`Failed to create bookshelf '${name}'`)
|
toast.error(`Failed to create bookshelf '${name}'`);
|
||||||
console.error(`Failed to create bookshelf: `, error)
|
console.error(`Failed to create bookshelf: `, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async addBooksToShelf(shelfId: number | string, bookIds: number[]) {
|
async addBooksToShelf(shelfId: number | string, bookIds: number[]) {
|
||||||
try {
|
try {
|
||||||
const shelf = await addBooksToShelf({
|
const shelf = await addBooksToShelf({
|
||||||
shelf_id: shelfId,
|
shelf_id: shelfId,
|
||||||
book_ids: bookIds
|
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedBookshelves = bookshelves.map(shelf => {
|
this.updateShelf(shelf);
|
||||||
if (shelfIdsToUpdate.has(shelf.id)) {
|
|
||||||
// Count how many times this shelf appears across all deleted books
|
if (bookIds.length === 1) toast.success(`Added book to shelf!`);
|
||||||
let decrementBy = 0;
|
else toast.success(`Added ${bookIds.length} books to shelf!`);
|
||||||
books.forEach(book => {
|
} catch (error) {
|
||||||
if (book.lists.some(s => s.id === shelf.id)) {
|
console.error('Failed to add book(s) to shelf: ', error);
|
||||||
decrementBy++;
|
toast.error('Failed to add book(s) to shelf.');
|
||||||
}
|
|
||||||
});
|
|
||||||
return { ...shelf, total: shelf.total - decrementBy };
|
|
||||||
}
|
|
||||||
return shelf;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.libraryBookshelves.set(libraryId, updatedBookshelves);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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');
|
const BOOKSHELF_KEY = Symbol('BOOKSHELF');
|
||||||
|
|
||||||
export function setBookshelfState() {
|
export function setBookshelfState() {
|
||||||
|
|||||||
@@ -32,9 +32,12 @@ export class LibraryState {
|
|||||||
this.activeLibrary = this.libraries.find((lib) => lib.id === libraryId) || this.libraries[0];
|
this.activeLibrary = this.libraries.find((lib) => lib.id === libraryId) || this.libraries[0];
|
||||||
if (browser) {
|
if (browser) {
|
||||||
localStorage.setItem('previousLibraryId', this.activeLibrary.id.toString());
|
localStorage.setItem('previousLibraryId', this.activeLibrary.id.toString());
|
||||||
await goto(resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(libraryId) }), {
|
await goto(
|
||||||
invalidate: ['app:libraries']
|
resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(libraryId) }),
|
||||||
});
|
{
|
||||||
|
invalidate: ['app:libraries']
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ export const FONT_STACKS: { label: string; value: string }[] = [
|
|||||||
{ label: 'Palatino', value: "'Palatino Linotype', Palatino, 'Book Antiqua', serif" },
|
{ label: 'Palatino', value: "'Palatino Linotype', Palatino, 'Book Antiqua', serif" },
|
||||||
{ label: 'Helvetica', value: "'Helvetica Neue', Helvetica, Arial, sans-serif" },
|
{ label: 'Helvetica', value: "'Helvetica Neue', Helvetica, Arial, sans-serif" },
|
||||||
{ label: 'Verdana', value: 'Verdana, Geneva, 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>;
|
export type Palette = Record<string, string>;
|
||||||
@@ -299,7 +302,10 @@ export function parseThemeCookie(raw: string | undefined | null): ThemeConfig {
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(decodeURIComponent(raw));
|
const parsed = JSON.parse(decodeURIComponent(raw));
|
||||||
if (!parsed || typeof parsed !== 'object') return { preset: DEFAULT_PRESET_ID };
|
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 {
|
} catch {
|
||||||
return { preset: DEFAULT_PRESET_ID };
|
return { preset: DEFAULT_PRESET_ID };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ export class ThemeState {
|
|||||||
readonly isCustomised = $derived(
|
readonly isCustomised = $derived(
|
||||||
Boolean(
|
Boolean(
|
||||||
this.config.radius ||
|
this.config.radius ||
|
||||||
this.config.fonts ||
|
this.config.fonts ||
|
||||||
Object.keys(this.config.light ?? {}).length ||
|
Object.keys(this.config.light ?? {}).length ||
|
||||||
Object.keys(this.config.dark ?? {}).length
|
Object.keys(this.config.dark ?? {}).length
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||||
import * as Empty from '$lib/components/ui/empty/index.js'
|
import * as Empty from '$lib/components/ui/empty/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js'
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import BookList from '$lib/components/view/book-list.svelte';
|
import BookList from '$lib/components/view/book-list.svelte';
|
||||||
import { FolderX, Upload } from '@lucide/svelte';
|
import { FolderX, Upload } from '@lucide/svelte';
|
||||||
import { getBookOperationsState } from '$lib/state/bookOperations.svelte.js';
|
import { getBookOperationsState } from '$lib/state/bookOperations.svelte.js';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
let bookOps = getBookOperationsState()
|
let bookOps = getBookOperationsState();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ScrollArea class="h-full w-full">
|
<ScrollArea class="h-full w-full">
|
||||||
@@ -22,7 +21,7 @@
|
|||||||
|
|
||||||
<!-- Empty when no preset returned anything, i.e. the library has no books -->
|
<!-- Empty when no preset returned anything, i.e. the library has no books -->
|
||||||
{#if data.presets.every((preset) => data.shelves[preset.id].length === 0)}
|
{#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 -->
|
<!-- Show a CTA to upload books if the library is empty -->
|
||||||
<Empty.Root class="mb-[12vh]">
|
<Empty.Root class="mb-[12vh]">
|
||||||
<Empty.Header>
|
<Empty.Header>
|
||||||
|
|||||||
@@ -44,7 +44,6 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<div class="[--header-height:calc(--spacing(14))]">
|
<div class="[--header-height:calc(--spacing(14))]">
|
||||||
<Sidebar.Provider class="flex flex-col">
|
<Sidebar.Provider class="flex flex-col">
|
||||||
<div class="flex h-screen">
|
<div class="flex h-screen">
|
||||||
|
|||||||
@@ -71,7 +71,8 @@
|
|||||||
|
|
||||||
<!-- Colours -->
|
<!-- Colours -->
|
||||||
<section class="flex flex-col gap-3">
|
<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">
|
<div class="grid gap-x-6 gap-y-1 sm:grid-cols-2">
|
||||||
{#each COLOR_TOKENS as token (token.key)}
|
{#each COLOR_TOKENS as token (token.key)}
|
||||||
<div class="flex items-center gap-3 rounded-md px-2 py-2 hover:bg-muted">
|
<div class="flex items-center gap-3 rounded-md px-2 py-2 hover:bg-muted">
|
||||||
@@ -94,7 +95,8 @@
|
|||||||
|
|
||||||
<!-- Radius -->
|
<!-- Radius -->
|
||||||
<section class="flex flex-col gap-3">
|
<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">
|
<div class="flex flex-wrap gap-2">
|
||||||
{#each RADII as option (option.value)}
|
{#each RADII as option (option.value)}
|
||||||
<button
|
<button
|
||||||
@@ -138,7 +140,8 @@
|
|||||||
|
|
||||||
<!-- Preview -->
|
<!-- Preview -->
|
||||||
<section class="flex flex-col gap-3">
|
<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="rounded-lg border bg-sidebar p-4">
|
||||||
<div class="flex flex-col gap-4 rounded-lg border bg-card 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">
|
<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() {
|
export async function load() {
|
||||||
const devices = await listDevices();
|
const devices = await listDevices();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
devices
|
devices
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user