Compare commits

..

6 Commits

Author SHA1 Message Date
930dbe9ba4 update dependencies
Migrate to the new SvelteKit invalid() API from the deprecated issue() api
for forms using remote functions.
2026-03-08 16:24:19 -04:00
c67ca0e1df Handle optional book.path to support books without files
Books may not have a path (e.g., physical books, metadata-only entries).
Updated path-dependent operations to handle None gracefully:

- get_file: raise ValueError if book has no path
- update_book: skip path relocation if no path exists
- remove_files: skip filesystem cleanup if no path exists

Also fixed _save_book_files return type and removed unused imports.
2026-03-07 12:38:04 -05:00
3a5ea1d158 fix typecheck errors in test_book_service.py 2026-03-07 12:17:29 -05:00
8117f0dbfe rename BookService CRUD overrides to domain-specific methods
The create, update, and delete methods had incompatible signatures
resulting in typecheck errors. Renamed to create_book, update_book, and
delete_books.
2026-03-07 11:58:24 -05:00
67fab3f9c6 fix update progress test to match new progress schema
"progress" field was renamed to "percentage"
2026-03-07 11:44:08 -05:00
a19c944b6e Fix hardcoded absolute paths in book upload tests
The Darwin epub test cases used absolute paths. I hadn't notices until switching machines, whcih caused errors in the test harness. Changed to relative paths to be consistent with other test cases.
2026-03-07 11:35:30 -05:00
7 changed files with 1226 additions and 1419 deletions

View File

@@ -85,7 +85,7 @@ class BookController(Controller):
""" """
result = await books_service.create(data, library) result = await books_service.create_book(data, library)
book = await books_service.get(result.id) book = await books_service.get(result.id)
return books_service.to_schema(book, schema_type=s.BookRead) return books_service.to_schema(book, schema_type=s.BookRead)
@@ -213,7 +213,7 @@ class BookController(Controller):
Returns: Returns:
The updated book as a BookRead schema. The updated book as a BookRead schema.
""" """
await books_service.update(book_id, data, library) await books_service.update_book(book_id, data, library)
book = await books_service.get(book_id) book = await books_service.get(book_id)
return books_service.to_schema(book, schema_type=s.BookRead) return books_service.to_schema(book, schema_type=s.BookRead)
@@ -244,7 +244,7 @@ class BookController(Controller):
The updated book as a BookRead schema. The updated book as a BookRead schema.
""" """
await books_service.update(book_id, {"cover_image": data}, library) await books_service.update_book(book_id, {"cover_image": data}, library)
updated_book = await books_service.get(book_id) updated_book = await books_service.get(book_id)
return books_service.to_schema(updated_book, schema_type=s.BookRead) return books_service.to_schema(updated_book, schema_type=s.BookRead)
@@ -386,7 +386,7 @@ class BookController(Controller):
""" """
await books_service.delete(book_ids, library, delete_files=delete_files) await books_service.delete_books(book_ids, library, delete_files=delete_files)
@post(path="/progress/{book_id:int}") @post(path="/progress/{book_id:int}")
async def update_progress( async def update_progress(

View File

@@ -17,6 +17,7 @@ from advanced_alchemy.service import (
SQLAlchemyAsyncRepositoryService, SQLAlchemyAsyncRepositoryService,
ModelDictT, ModelDictT,
is_dict, is_dict,
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
@@ -37,18 +38,14 @@ from chitai.database.models import (
BookSeries, BookSeries,
FileMetadata, FileMetadata,
Identifier, Identifier,
BookList,
Library, Library,
) )
from chitai.database.models.book_progress import BookProgress
from chitai.schemas.book import BooksCreateFromFiles from chitai.schemas.book import BooksCreateFromFiles
from chitai.services.filesystem_library import BookPathGenerator from chitai.services.filesystem_library import BookPathGenerator
from chitai.services.metadata_extractor import Extractor as MetadataExtractor from chitai.services.metadata_extractor import Extractor as MetadataExtractor
from chitai.services.utils import ( from chitai.services.utils import (
cleanup_empty_parent_directories, cleanup_empty_parent_directories,
delete_directory,
delete_file, delete_file,
is_empty,
move_dir_contents, move_dir_contents,
move_file, move_file,
save_image, save_image,
@@ -67,7 +64,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
async def create(self, data: ModelDictT[Book], library: Library, **kwargs) -> Book: async def create_book(self, data: ModelDictT[Book], library: Library, **kwargs) -> Book:
""" """
Create a new book entity. Create a new book entity.
@@ -83,9 +80,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
Returns: Returns:
The created Book entity. The created Book entity.
""" """
if not is_dict(data): data = schema_dump(data)
data = data.model_dump()
await self._parse_metadata_from_files(data) await self._parse_metadata_from_files(data)
await self._save_cover_image(data) await self._save_cover_image(data)
@@ -126,7 +121,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
books[filepath].append(file) books[filepath].append(file)
return [ return [
await self.create( await self.create_book(
{"files": [file for file in files], "library_id": library.id}, {"files": [file for file in files], "library_id": library.id},
library, library,
**kwargs, **kwargs,
@@ -211,7 +206,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
return books return books
async def delete( async def delete_books(
self, self,
book_ids: list[int], book_ids: list[int],
library: Library, library: Library,
@@ -263,6 +258,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
ValueError: If the file is missing or not found for the given book. ValueError: If the file is missing or not found for the given book.
""" """
book = await self.get(book_id) book = await self.get(book_id)
if book.path is None:
raise ValueError("Cannot download file: book has no path")
for file in book.files: for file in book.files:
if file.id != file_id: if file.id != file_id:
continue continue
@@ -309,7 +307,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
break break
yield chunk yield chunk
async def update( async def update_book(
self, book_id: int, update_data: ModelDictT[Book], library: Library self, book_id: int, update_data: ModelDictT[Book], library: Library
) -> Book: ) -> Book:
""" """
@@ -343,7 +341,8 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
await self._save_cover_image(data) await self._save_cover_image(data)
# TODO: extract out into its own function _update_book_path # TODO: extract out into its own function _update_book_path
# Check if file path must be updated # Check if file path must be updated (only for books with files)
if book.path is not None:
path_gen = BookPathGenerator(library.root_path) path_gen = BookPathGenerator(library.root_path)
updated_path = path_gen.generate_path(book.to_dict() | data) updated_path = path_gen.generate_path(book.to_dict() | data)
if str(updated_path) != book.path: if str(updated_path) != book.path:
@@ -368,9 +367,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
book = await self.get(book_id) book = await self.get(book_id)
data = book.to_dict() data = book.to_dict()
data["files"] = files data["files"] = files
await self._save_book_files(library, data) new_files = await self._save_book_files(library, data)
book.files.extend(data["files"]) book.files.extend(new_files)
await self.update(book.id, {"files": [file for file in book.files]}, library) await self.update_book(book.id, {"files": [file for file in book.files]}, library)
async def remove_files( async def remove_files(
self, book_id: int, file_ids: list[int], delete_files: bool, library: Library self, book_id: int, file_ids: list[int], delete_files: bool, library: Library
@@ -388,7 +387,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
""" """
book = await self.get_one(Book.id == book_id, Book.library_id == library.id) book = await self.get_one(Book.id == book_id, Book.library_id == library.id)
if delete_files: if delete_files and book.path is not None:
# TODO: Extract this out into its own function # TODO: Extract this out into its own function
for file in (file for file in book.files if file.id in file_ids): for file in (file for file in book.files if file.id in file_ids):
full_path = Path(book.path) / Path(file.path) full_path = Path(book.path) / Path(file.path)
@@ -447,7 +446,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
return data return data
async def _populate_with_unique_relationships(self, data: 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.
@@ -509,7 +508,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
return model_data return model_data
async def _save_book_files(self, library: Library, data: dict) -> dict: async def _save_book_files(self, library: Library, data: dict) -> list[FileMetadata]:
""" """
Save uploaded book files to the filesystem. Save uploaded book files to the filesystem.
@@ -559,7 +558,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
) )
data["files"] = file_metadata data["files"] = file_metadata
return data 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:
""" """

View File

@@ -28,7 +28,7 @@ from pathlib import Path
), ),
( (
Path( Path(
"/home/patrick/projects/chitai-api/tests/data_files/On The Origin of Species By Means of Natural Selection - Charles Darwin.epub" "tests/data_files/On The Origin of Species By Means of Natural Selection - Charles Darwin.epub"
), ),
2, 2,
"On the Origin of Species By Means of Natural Selection / Or, the Preservation of Favoured Races in the Struggle for Life", "On the Origin of Species By Means of Natural Selection / Or, the Preservation of Favoured Races in the Struggle for Life",
@@ -107,7 +107,7 @@ async def test_upload_book_without_data(
), ),
( (
Path( Path(
"/home/patrick/projects/chitai-api/tests/data_files/On The Origin of Species By Means of Natural Selection - Charles Darwin.epub" "tests/data_files/On The Origin of Species By Means of Natural Selection - Charles Darwin.epub"
), ),
2, 2,
"On the Origin of Species By Means of Natural Selection", "On the Origin of Species By Means of Natural Selection",
@@ -327,7 +327,7 @@ async def test_update_reading_progress(
# Update progress # Update progress
progress_data = { progress_data = {
"progress": 0.5, "percentage": 0.5,
} }
response = await populated_authenticated_client.post( response = await populated_authenticated_client.post(
@@ -343,7 +343,7 @@ async def test_update_reading_progress(
assert response.status_code == 200 assert response.status_code == 200
book = response.json() book = response.json()
assert book["progress"]["progress"] == 0.5 assert book["progress"]["percentage"] == 0.5
async def test_create_multiple_books_from_directory( async def test_create_multiple_books_from_directory(

View File

@@ -1,10 +1,6 @@
"""Tests for BookService""" """Tests for BookService"""
import pytest import pytest
from pathlib import Path
import aiofiles.os as aios import aiofiles.os as aios
from chitai.schemas import BookCreate from chitai.schemas import BookCreate
@@ -29,16 +25,17 @@ class TestBookServiceCRUD:
) )
book = await books_service.to_model_on_create(book_data.model_dump()) book = await books_service.to_model_on_create(book_data.model_dump())
assert isinstance(book, m.Book)
# Add path manually as it won't be generated (not using the create function, but manually inserting into db) # Add path manually as it won't be generated (not using the create function, but manually inserting into db)
book.path = f"{test_library.root_path}/J.R.R Tolkien/The Fellowship of the Ring" book.path = f"{test_library.root_path}/J.R.R Tolkien/The Fellowship of the Ring"
await aios.makedirs(book.path) await aios.makedirs(book.path) # type: ignore[arg-type]
books_service.repository.session.add(book) books_service.repository.session.add(book)
await books_service.repository.session.commit() await books_service.repository.session.commit()
await books_service.repository.session.refresh(book) await books_service.repository.session.refresh(book)
await books_service.update( await books_service.update_book(
book.id, book.id,
{ {
"title": "The Fellowship of the Ring", "title": "The Fellowship of the Ring",
@@ -66,9 +63,7 @@ class TestBookServiceCRUD:
assert updated_book.identifiers[0].value == "9780261102354" assert updated_book.identifiers[0].value == "9780261102354"
assert updated_book.edition == 3 assert updated_book.edition == 3
assert updated_book.publisher is not None
assert updated_book.publisher.name == "Tolkien Estate" assert updated_book.publisher.name == "Tolkien Estate"
assert len(updated_book.tags) == 2 assert len(updated_book.tags) == 2
# book = await books_service.create(book_data.model_dump())

View File

@@ -14,42 +14,42 @@
"lint": "prettier --check . && eslint ." "lint": "prettier --check . && eslint ."
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.4.0", "@eslint/compat": "^1.4.1",
"@eslint/js": "^9.38.0", "@eslint/js": "^9.39.4",
"@iconify/svelte": "^5.0.2", "@iconify/svelte": "^5.2.1",
"@internationalized/date": "^3.10.0", "@internationalized/date": "^3.12.0",
"@lucide/svelte": "^0.544.0", "@lucide/svelte": "^0.544.0",
"@sveltejs/adapter-node": "^5.4.0", "@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.48.0", "@sveltejs/kit": "^2.53.4",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.2.1",
"@types/node": "^22.18.12", "@types/node": "^22.19.15",
"bits-ui": "^2.14.0", "bits-ui": "^2.16.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"eslint": "^9.38.0", "eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.12.5", "eslint-plugin-svelte": "^3.15.0",
"globals": "^16.4.0", "globals": "^16.5.0",
"jsrepo": "^2.5.0", "jsrepo": "^2.5.2",
"openapi-typescript": "^7.10.1", "openapi-typescript": "^7.13.0",
"prettier": "^3.6.2", "prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.5.1",
"prettier-plugin-tailwindcss": "^0.6.14", "prettier-plugin-tailwindcss": "^0.6.14",
"svelte": "^5.41.0", "svelte": "^5.53.7",
"svelte-check": "^4.3.3", "svelte-check": "^4.4.5",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.5.0",
"tailwind-scrollbar": "^4.0.2", "tailwind-scrollbar": "^4.0.2",
"tailwind-variants": "^3.1.1", "tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.16", "tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.46.2", "typescript-eslint": "^8.56.1",
"vite": "^7.1.12" "vite": "^7.3.1"
}, },
"dependencies": { "dependencies": {
"epubjs": "^0.3.93", "epubjs": "^0.3.93",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"svelte-sonner": "^1.0.5", "svelte-sonner": "^1.0.8",
"zod": "^4.1.12" "zod": "^4.3.6"
} }
} }

2499
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
import { command, form, getRequestEvent, query } from '$app/server'; import { command, form, getRequestEvent, query } from '$app/server';
import { loginSchema, signupSchema } from '$lib/schema/auth'; import { loginSchema, signupSchema } from '$lib/schema/auth';
import { BACKEND_API_URL } from '$lib/server/config'; import { BACKEND_API_URL } from '$lib/server/config';
import { redirect } from '@sveltejs/kit'; import { invalid, redirect } from '@sveltejs/kit';
export const login = form(loginSchema, async (data, invalid) => { export const login = form(loginSchema, async (data, issue) => {
const { cookies, locals } = getRequestEvent(); const { cookies, locals } = getRequestEvent();
// Create URL-encoded form data // Create URL-encoded form data
@@ -19,11 +19,11 @@ export const login = form(loginSchema, async (data, invalid) => {
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
invalid(invalid.email('Invalid login credentials')); invalid(issue.email('Invalid login credentials'));
} else { } else {
const message = await response.text(); const message = await response.text();
console.error('Unknown error: ', message); console.error('Unknown error: ', message);
invalid(invalid.email('An unknown error occurred')); invalid(issue.email('An unknown error occurred'));
} }
} }
@@ -40,7 +40,7 @@ export const login = form(loginSchema, async (data, invalid) => {
redirect(303, '/'); redirect(303, '/');
}); });
export const signup = form(signupSchema, async (data, invalid) => { export const signup = form(signupSchema, async (data, issue) => {
const response = await fetch(`${BACKEND_API_URL}/access/signup`, { const response = await fetch(`${BACKEND_API_URL}/access/signup`, {
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
@@ -49,11 +49,11 @@ export const signup = form(signupSchema, async (data, invalid) => {
if (!response.ok) { if (!response.ok) {
if (response.status == 409) { if (response.status == 409) {
invalid(invalid.email('Email is already in use by another account')); invalid(issue.email('Email is already in use by another account'));
} else { } else {
const message = await response.text(); const message = await response.text();
console.error('Unknown error: ', message); console.error('Unknown error: ', message);
invalid(invalid.email('An unknown error occurred')); invalid(issue.email('An unknown error occurred'));
} }
} }
}); });