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)
return books_service.to_schema(book, schema_type=s.BookRead)
@@ -213,7 +213,7 @@ class BookController(Controller):
Returns:
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)
return books_service.to_schema(book, schema_type=s.BookRead)
@@ -244,7 +244,7 @@ class BookController(Controller):
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)
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}")
async def update_progress(

View File

@@ -17,6 +17,7 @@ from advanced_alchemy.service import (
SQLAlchemyAsyncRepositoryService,
ModelDictT,
is_dict,
schema_dump
)
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
from advanced_alchemy.filters import CollectionFilter
@@ -37,18 +38,14 @@ from chitai.database.models import (
BookSeries,
FileMetadata,
Identifier,
BookList,
Library,
)
from chitai.database.models.book_progress import BookProgress
from chitai.schemas.book import BooksCreateFromFiles
from chitai.services.filesystem_library import BookPathGenerator
from chitai.services.metadata_extractor import Extractor as MetadataExtractor
from chitai.services.utils import (
cleanup_empty_parent_directories,
delete_directory,
delete_file,
is_empty,
move_dir_contents,
move_file,
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.
@@ -83,9 +80,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
Returns:
The created Book entity.
"""
if not is_dict(data):
data = data.model_dump()
data = schema_dump(data)
await self._parse_metadata_from_files(data)
await self._save_cover_image(data)
@@ -126,7 +121,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
books[filepath].append(file)
return [
await self.create(
await self.create_book(
{"files": [file for file in files], "library_id": library.id},
library,
**kwargs,
@@ -211,7 +206,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
return books
async def delete(
async def delete_books(
self,
book_ids: list[int],
library: Library,
@@ -263,6 +258,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
ValueError: If the file is missing or not found for the given book.
"""
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:
if file.id != file_id:
continue
@@ -309,7 +307,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
break
yield chunk
async def update(
async def update_book(
self, book_id: int, update_data: ModelDictT[Book], library: Library
) -> Book:
"""
@@ -343,14 +341,15 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
await self._save_cover_image(data)
# TODO: extract out into its own function _update_book_path
# Check if file path must be updated
path_gen = BookPathGenerator(library.root_path)
updated_path = path_gen.generate_path(book.to_dict() | data)
if str(updated_path) != book.path:
# TODO: Move only the files associated with the book instead of the whole directory
await move_dir_contents(book.path, updated_path)
data["path"] = str(updated_path)
cleanup_empty_parent_directories(Path(book.path), Path(library.root_path))
# Check if file path must be updated (only for books with files)
if book.path is not None:
path_gen = BookPathGenerator(library.root_path)
updated_path = path_gen.generate_path(book.to_dict() | data)
if str(updated_path) != book.path:
# TODO: Move only the files associated with the book instead of the whole directory
await move_dir_contents(book.path, updated_path)
data["path"] = str(updated_path)
cleanup_empty_parent_directories(Path(book.path), Path(library.root_path))
return await super().update(data, item_id=book_id, execution_options={"populate_existing": True})
@@ -368,9 +367,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
book = await self.get(book_id)
data = book.to_dict()
data["files"] = files
await self._save_book_files(library, data)
book.files.extend(data["files"])
await self.update(book.id, {"files": [file for file in book.files]}, library)
new_files = await self._save_book_files(library, data)
book.files.extend(new_files)
await self.update_book(book.id, {"files": [file for file in book.files]}, library)
async def remove_files(
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)
if delete_files:
if delete_files and book.path is not None:
# TODO: Extract this out into its own function
for file in (file for file in book.files if file.id in file_ids):
full_path = Path(book.path) / Path(file.path)
@@ -447,7 +446,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
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.
@@ -509,7 +508,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
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.
@@ -559,7 +558,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
)
data["files"] = file_metadata
return data
return data["files"]
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(
"/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,
"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(
"/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,
"On the Origin of Species By Means of Natural Selection",
@@ -327,7 +327,7 @@ async def test_update_reading_progress(
# Update progress
progress_data = {
"progress": 0.5,
"percentage": 0.5,
}
response = await populated_authenticated_client.post(
@@ -343,7 +343,7 @@ async def test_update_reading_progress(
assert response.status_code == 200
book = response.json()
assert book["progress"]["progress"] == 0.5
assert book["progress"]["percentage"] == 0.5
async def test_create_multiple_books_from_directory(

View File

@@ -1,10 +1,6 @@
"""Tests for BookService"""
import pytest
from pathlib import Path
import aiofiles.os as aios
from chitai.schemas import BookCreate
@@ -29,16 +25,17 @@ class TestBookServiceCRUD:
)
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)
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)
await books_service.repository.session.commit()
await books_service.repository.session.refresh(book)
await books_service.update(
await books_service.update_book(
book.id,
{
"title": "The Fellowship of the Ring",
@@ -66,9 +63,7 @@ class TestBookServiceCRUD:
assert updated_book.identifiers[0].value == "9780261102354"
assert updated_book.edition == 3
assert updated_book.publisher is not None
assert updated_book.publisher.name == "Tolkien Estate"
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 ."
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.38.0",
"@iconify/svelte": "^5.0.2",
"@internationalized/date": "^3.10.0",
"@eslint/compat": "^1.4.1",
"@eslint/js": "^9.39.4",
"@iconify/svelte": "^5.2.1",
"@internationalized/date": "^3.12.0",
"@lucide/svelte": "^0.544.0",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.48.0",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.16",
"@types/node": "^22.18.12",
"bits-ui": "^2.14.0",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.53.4",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.1",
"@types/node": "^22.19.15",
"bits-ui": "^2.16.3",
"clsx": "^2.1.1",
"eslint": "^9.38.0",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.12.5",
"globals": "^16.4.0",
"jsrepo": "^2.5.0",
"openapi-typescript": "^7.10.1",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"eslint-plugin-svelte": "^3.15.0",
"globals": "^16.5.0",
"jsrepo": "^2.5.2",
"openapi-typescript": "^7.13.0",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.5.1",
"prettier-plugin-tailwindcss": "^0.6.14",
"svelte": "^5.41.0",
"svelte-check": "^4.3.3",
"tailwind-merge": "^3.3.1",
"svelte": "^5.53.7",
"svelte-check": "^4.4.5",
"tailwind-merge": "^3.5.0",
"tailwind-scrollbar": "^4.0.2",
"tailwind-variants": "^3.1.1",
"tailwindcss": "^4.1.16",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.2",
"vite": "^7.1.12"
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1"
},
"dependencies": {
"epubjs": "^0.3.93",
"mode-watcher": "^1.1.0",
"svelte-sonner": "^1.0.5",
"zod": "^4.1.12"
"svelte-sonner": "^1.0.8",
"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 { loginSchema, signupSchema } from '$lib/schema/auth';
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();
// Create URL-encoded form data
@@ -19,11 +19,11 @@ export const login = form(loginSchema, async (data, invalid) => {
if (!response.ok) {
if (response.status === 401) {
invalid(invalid.email('Invalid login credentials'));
invalid(issue.email('Invalid login credentials'));
} else {
const message = await response.text();
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, '/');
});
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`, {
method: 'POST',
body: JSON.stringify(data),
@@ -49,11 +49,11 @@ export const signup = form(signupSchema, async (data, invalid) => {
if (!response.ok) {
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 {
const message = await response.text();
console.error('Unknown error: ', message);
invalid(invalid.email('An unknown error occurred'));
invalid(issue.email('An unknown error occurred'));
}
}
});