Compare commits
6 Commits
766fca2c39
...
930dbe9ba4
| Author | SHA1 | Date | |
|---|---|---|---|
| 930dbe9ba4 | |||
| c67ca0e1df | |||
| 3a5ea1d158 | |||
| 8117f0dbfe | |||
| 67fab3f9c6 | |||
| a19c944b6e |
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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())
|
|
||||||
|
|||||||
@@ -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
2499
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user