diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 14b1a15..181ccb6 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -38,6 +38,11 @@ dev = [ "pytest-databases[postgres]>=0.15.0", ] +[tool.ruff.lint.per-file-ignores] +# The package __init__.py files exist to re-export their modules' public names, so every +# import in them is "unused" as far as F401 is concerned. +"__init__.py" = ["F401"] + [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] diff --git a/backend/src/chitai/app.py b/backend/src/chitai/app.py index 9dfb72a..c8b6c6a 100644 --- a/backend/src/chitai/app.py +++ b/backend/src/chitai/app.py @@ -72,6 +72,7 @@ oauth2_auth = OAuth2PasswordBearerAuth[User]( watcher_task: asyncio.Task + @asynccontextmanager async def setup_db_connection(app: Litestar) -> AsyncGenerator[None, None]: # Setup databse @@ -97,7 +98,7 @@ async def setup_db_connection(app: Litestar) -> AsyncGenerator[None, None]: @asynccontextmanager async def setup_directory_watcher(app: Litestar) -> AsyncGenerator[None, None]: - + # Create book covers directory if it does not exist await create_directory(settings.book_cover_path) # Create consume directory @@ -107,14 +108,17 @@ async def setup_directory_watcher(app: Litestar) -> AsyncGenerator[None, None]: book_service = BookService(session=db_session) library_service = LibraryService(session=db_session) - file_watcher = ConsumeDirectoryWatcher(settings.consume_path, library_service, book_service) + file_watcher = ConsumeDirectoryWatcher( + settings.consume_path, library_service, book_service + ) watcher_task = asyncio.create_task(file_watcher.init_watcher()) - + try: yield finally: watcher_task.cancel() + def create_app() -> Litestar: return Litestar( route_handlers=[ diff --git a/backend/src/chitai/controllers/access.py b/backend/src/chitai/controllers/access.py index b95d67a..6cc7835 100644 --- a/backend/src/chitai/controllers/access.py +++ b/backend/src/chitai/controllers/access.py @@ -1,7 +1,7 @@ # src/chitai/controllers/access.py # Standard library -from typing import Annotated, Any +from typing import Annotated import logging # Third-party libraries diff --git a/backend/src/chitai/controllers/author.py b/backend/src/chitai/controllers/author.py index 3a16690..af6814d 100644 --- a/backend/src/chitai/controllers/author.py +++ b/backend/src/chitai/controllers/author.py @@ -4,9 +4,8 @@ from typing import Annotated # Third-party libraries -from litestar import Controller, post, get, patch, delete +from litestar import Controller, get from litestar.params import Dependency -from litestar.exceptions import HTTPException from advanced_alchemy.extensions.litestar.providers import create_service_dependencies from advanced_alchemy.service.pagination import OffsetPagination from advanced_alchemy.service import FilterTypeT diff --git a/backend/src/chitai/controllers/kosync_device.py b/backend/src/chitai/controllers/kosync_device.py index 4f284bc..aba1592 100644 --- a/backend/src/chitai/controllers/kosync_device.py +++ b/backend/src/chitai/controllers/kosync_device.py @@ -8,47 +8,51 @@ from litestar import Controller, post, get, delete from litestar.di import Provide from chitai.services import dependencies as deps -class DeviceController(Controller): - """ Controller for managing KOReader devices.""" - dependencies = { - "device_service": Provide(deps.provide_kosync_device_service) - } +class DeviceController(Controller): + """Controller for managing KOReader devices.""" + + dependencies = {"device_service": Provide(deps.provide_kosync_device_service)} path = "/devices" - @get() - async def get_devices(self, device_service: KosyncDeviceService, current_user: User) -> OffsetPagination[KosyncDeviceRead]: - """ Return a list of all the user's devices.""" - devices = await device_service.list( - KosyncDevice.user_id == current_user.id - ) + async def get_devices( + self, device_service: KosyncDeviceService, current_user: User + ) -> OffsetPagination[KosyncDeviceRead]: + """Return a list of all the user's devices.""" + devices = await device_service.list(KosyncDevice.user_id == current_user.id) return device_service.to_schema(devices, schema_type=KosyncDeviceRead) - + @post() - async def create_device(self, data: KosyncDeviceCreate, device_service: KosyncDeviceService, current_user: User) -> KosyncDeviceRead: - device = await device_service.create({ - 'name': data.name, - 'user_id': current_user.id - }) + async def create_device( + self, + data: KosyncDeviceCreate, + device_service: KosyncDeviceService, + current_user: User, + ) -> KosyncDeviceRead: + device = await device_service.create( + {"name": data.name, "user_id": current_user.id} + ) return device_service.to_schema(device, schema_type=KosyncDeviceRead) - + @delete("/{device_id:int}") - async def delete_device(self, device_id: int, device_service: KosyncDeviceService, current_user: User) -> None: + async def delete_device( + self, device_id: int, device_service: KosyncDeviceService, current_user: User + ) -> None: # Ensure the device exists and is owned by the user device = await device_service.get_one( - KosyncDevice.id == device_id, - KosyncDevice.user_id == current_user.id + KosyncDevice.id == device_id, KosyncDevice.user_id == current_user.id ) await device_service.delete(device.id) - + @get("/{device_id:int}/regenerate") - async def regenerate_device_api_key(self, device_id: int, device_service: KosyncDeviceService, current_user: User) -> KosyncDeviceRead: + async def regenerate_device_api_key( + self, device_id: int, device_service: KosyncDeviceService, current_user: User + ) -> KosyncDeviceRead: # Ensure the device exists and is owned by the user device = await device_service.get_one( - KosyncDevice.id == device_id, - KosyncDevice.user_id == current_user.id + KosyncDevice.id == device_id, KosyncDevice.user_id == current_user.id ) updated_device = await device_service.regenerate_api_key(device.id) - return device_service.to_schema(updated_device, schema_type=KosyncDeviceRead) \ No newline at end of file + return device_service.to_schema(updated_device, schema_type=KosyncDeviceRead) diff --git a/backend/src/chitai/controllers/kosync_progress.py b/backend/src/chitai/controllers/kosync_progress.py index 5f773f2..4b16c3f 100644 --- a/backend/src/chitai/controllers/kosync_progress.py +++ b/backend/src/chitai/controllers/kosync_progress.py @@ -58,10 +58,14 @@ class KosyncController(Controller): user: m.User, ) -> KosyncProgressRead: """Return the Kosync progress record associated with the given document.""" - progress = await kosync_progress_service.get_by_document_hash(user.id, document_id) + progress = await kosync_progress_service.get_by_document_hash( + user.id, document_id + ) if not progress: - raise HTTPException(status_code=404, detail="No progress found for document") + raise HTTPException( + status_code=404, detail="No progress found for document" + ) return KosyncProgressRead( document=progress.document, @@ -85,6 +89,3 @@ class KosyncController(Controller): detail="User accounts must be created via the main application", status_code=HTTP_403_FORBIDDEN, ) - - - diff --git a/backend/src/chitai/controllers/library.py b/backend/src/chitai/controllers/library.py index f73dd50..ca01fb6 100644 --- a/backend/src/chitai/controllers/library.py +++ b/backend/src/chitai/controllers/library.py @@ -10,7 +10,7 @@ from typing import Annotated # Third-party libraries import aiofiles from aiofiles import os as aios -from litestar import Controller, post, get, patch, delete +from litestar import Controller, post, get, delete from litestar.enums import RequestEncodingType from litestar.params import Body, Dependency from litestar.exceptions import HTTPException @@ -89,7 +89,9 @@ class LibraryController(Controller): Injected Dependencies: library_service: The service used to query and return library data. """ - results, total = await library_service.list_and_count(*filters, load=[m.Library.books]) + results, total = await library_service.list_and_count( + *filters, load=[m.Library.books] + ) return library_service.to_schema( results, total, filters, schema_type=LibraryRead ) diff --git a/backend/src/chitai/controllers/opds.py b/backend/src/chitai/controllers/opds.py index cb37efa..a5b20cb 100644 --- a/backend/src/chitai/controllers/opds.py +++ b/backend/src/chitai/controllers/opds.py @@ -1,4 +1,3 @@ - from chitai.services import dependencies as deps from chitai.database import models as m from chitai.services.author import AuthorService @@ -6,7 +5,15 @@ from chitai.services.filters.author import AuthorLibraryFilter from chitai.services.filters.publisher import PublisherLibraryFilter from chitai.services.filters.tags import TagLibraryFilter from chitai.services.opds.models import Entry, Link, LinkTypes, LinkRelations -from chitai.services.opds.opds import create_acquisition_feed, create_navigation_feed, create_library_navigation_feed, create_collection_navigation_feed, create_pagination_links, create_search_link, get_opensearch_document +from chitai.services.opds.opds import ( + create_acquisition_feed, + create_navigation_feed, + create_library_navigation_feed, + create_collection_navigation_feed, + create_pagination_links, + create_search_link, + get_opensearch_document, +) from chitai.services import BookService, ShelfService, LibraryService from chitai.services.publisher import PublisherService from chitai.services.tag import TagService @@ -14,7 +21,7 @@ from litestar import Controller, Request, Response, get from litestar.response import File from litestar.di import Provide from litestar.exceptions import HTTPException -from advanced_alchemy.filters import CollectionFilter, LimitOffset, OrderBy +from advanced_alchemy.filters import CollectionFilter, LimitOffset, OrderBy from chitai.middleware.basic_auth import basic_auth_mw from urllib.parse import urlencode from typing import Annotated @@ -22,11 +29,10 @@ from litestar.params import Dependency from advanced_alchemy.service import FilterTypeT - class OpdsController(Controller): - """ Controller for managing OPDS endpoints """ + """Controller for managing OPDS endpoints""" - middleware=[basic_auth_mw] + middleware = [basic_auth_mw] dependencies = { "user": Provide(deps.provide_user_via_basic_auth), @@ -76,32 +82,30 @@ class OpdsController(Controller): title=lib.name, href=f"/opds/library/{lib.id}", rel=LinkRelations.SUBSECTION, - type=LinkTypes.NAVIGATION + type=LinkTypes.NAVIGATION, ) - ] - ) for lib in libraries + ], + ) + for lib in libraries ] feed = create_navigation_feed( - id="/opds", - title="Root", - self_url="/opds", - links=[ - Link( - rel="search", - href="/opds/opensearch", - type="application/opensearchdescription+xml", - title="Search books", - ) - ], - entries=entries - ) - - return Response( - feed, - media_type="application/xml" + id="/opds", + title="Root", + self_url="/opds", + links=[ + Link( + rel="search", + href="/opds/opensearch", + type="application/opensearchdescription+xml", + title="Search books", + ) + ], + entries=entries, ) - + + return Response(feed, media_type="application/xml") + @get("/acquisition") async def get_acquisition_feed( self, @@ -109,37 +113,41 @@ class OpdsController(Controller): feed_id: str, feed_title: str, books_service: BookService, - book_filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [], - filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [] + book_filters: Annotated[ + list[FilterTypeT], Dependency(skip_validation=True) + ] = [], + filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [], ) -> Response: - + all_filters = [*filters, *book_filters] books, total = await books_service.list_and_count(*all_filters) - + limit, offset = extract_limit_offset(all_filters) links = [] - + # Create pagination links if it is a paginated feed - if request.query_params.get('paginated'): + if request.query_params.get("paginated"): pagination = create_pagination_links( request=request, total=total, limit=limit, offset=offset, feed_title=feed_title, - link_type=LinkTypes.ACQUISITION + link_type=LinkTypes.ACQUISITION, ) - - links.extend([link for link in [pagination.next_link, pagination.prev_link] if link]) - + + links.extend( + [link for link in [pagination.next_link, pagination.prev_link] if link] + ) + # Add search link if this is a searchable feed - if request.query_params.get('search'): + if request.query_params.get("search"): links.append(create_search_link(request)) - + # Create self URL self_url = f"{request.url.path}?{urlencode(list(request.query_params.items()), doseq=True)}" - + feed = create_acquisition_feed( id=feed_id, title=feed_title, @@ -147,28 +155,26 @@ class OpdsController(Controller): books=books, links=links, ) - - return Response(feed, media_type="application/xml") + return Response(feed, media_type="application/xml") @get("/opensearch") async def opensearch(self, user: m.User, request: Request) -> Response: - + return Response( get_opensearch_document( - base_url=f'/opds/search?{urlencode(list(request.query_params.items()), doseq=True)}&' + base_url=f"/opds/search?{urlencode(list(request.query_params.items()), doseq=True)}&" ), - media_type="application/xml" + media_type="application/xml", ) - + @get("/library/{library_id:int}") async def get_library_feed(self, library: m.Library) -> Response: feed = create_library_navigation_feed(library) return Response(feed, media_type="application/xml") - - + @get("/library/{library_id:int}/{collection_type:str}") async def get_library_collection_feed( self, @@ -180,39 +186,51 @@ class OpdsController(Controller): tag_service: TagService, publisher_service: PublisherService, request: Request, - filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [] + filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [], ) -> Response: - + service_map = { - 'shelves': (shelf_service, lambda: shelf_service.list_and_count( - *filters, - CollectionFilter("library_id", values=[library.id]), - OrderBy("name", "asc"), - m.BookList.user_id == user.id - )), - 'tags': (tag_service, lambda: tag_service.list_and_count( - *filters, - TagLibraryFilter(libraries=[library.id]), - OrderBy("name", "asc"), - uniquify=True, - )), - 'authors': (author_service, lambda: author_service.list_and_count( - *filters, - AuthorLibraryFilter(libraries=[library.id]), - OrderBy("name", "asc"), - uniquify=True - )), - 'publishers': (publisher_service, lambda: publisher_service.list_and_count( - *filters, - PublisherLibraryFilter(libraries=[library.id]), - OrderBy("name", "asc"), - uniquify=True - )) + "shelves": ( + shelf_service, + lambda: shelf_service.list_and_count( + *filters, + CollectionFilter("library_id", values=[library.id]), + OrderBy("name", "asc"), + m.BookList.user_id == user.id, + ), + ), + "tags": ( + tag_service, + lambda: tag_service.list_and_count( + *filters, + TagLibraryFilter(libraries=[library.id]), + OrderBy("name", "asc"), + uniquify=True, + ), + ), + "authors": ( + author_service, + lambda: author_service.list_and_count( + *filters, + AuthorLibraryFilter(libraries=[library.id]), + OrderBy("name", "asc"), + uniquify=True, + ), + ), + "publishers": ( + publisher_service, + lambda: publisher_service.list_and_count( + *filters, + PublisherLibraryFilter(libraries=[library.id]), + OrderBy("name", "asc"), + uniquify=True, + ), + ), } - + if collection_type not in service_map: raise HTTPException(status_code=404, detail="Collection type not found") - + _, fetch_items = service_map[collection_type] items, total = await fetch_items() links = [] @@ -220,41 +238,40 @@ class OpdsController(Controller): # Create pagination links if it is a paginated feed limit, offset = extract_limit_offset(filters) - if request.query_params.get('paginated'): + if request.query_params.get("paginated"): pagination = create_pagination_links( request=request, total=total, limit=limit, offset=offset, feed_title=collection_type, - link_type=LinkTypes.ACQUISITION + link_type=LinkTypes.ACQUISITION, ) - - links.extend([link for link in [pagination.next_link, pagination.prev_link] if link]) - + + links.extend( + [link for link in [pagination.next_link, pagination.prev_link] if link] + ) + feed = create_collection_navigation_feed(library, collection_type, items, links) return Response(feed, media_type="application/xml") - - @get("/search") async def search_books( - self, books_service: BookService, + self, + books_service: BookService, request: Request, book_filters: Annotated[ list[FilterTypeT], Dependency(skip_validation=True) ] = [], - filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [] + filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [], ) -> Response: - + filters = [*filters, *book_filters] - books, total = await books_service.list_and_count( - *filters - ) + books, total = await books_service.list_and_count(*filters) limit, offset = extract_limit_offset(filters) - + # Create pagination links pagination = create_pagination_links( request=request, @@ -262,37 +279,34 @@ class OpdsController(Controller): limit=limit, offset=offset, feed_title="Search Results", - link_type=LinkTypes.ACQUISITION + link_type=LinkTypes.ACQUISITION, ) links = [link for link in [pagination.next_link, pagination.prev_link] if link] catalog_xml = create_acquisition_feed( - id=f"/opds/search?q=q", + id="/opds/search?q=q", title="Search results", - url=f"/opds/search?q=q", + url="/opds/search?q=q", books=books, - links=links + links=links, ) return Response(catalog_xml, media_type="application/xml") - + @get(path="download/{book_id:int}/{file_id:int}") async def get_file( self, book_id: int, file_id: int, books_service: BookService ) -> File: - + return await books_service.get_file(book_id, file_id) def extract_limit_offset(filters: list[FilterTypeT]) -> tuple[int, int]: """Extract page size and offset from filters""" - limit_offset_filter = next( - (f for f in filters if isinstance(f, LimitOffset)), - None - ) - + limit_offset_filter = next((f for f in filters if isinstance(f, LimitOffset)), None) + if limit_offset_filter: return limit_offset_filter.limit, limit_offset_filter.offset - - raise ValueError("LimitOffset filter not found") \ No newline at end of file + + raise ValueError("LimitOffset filter not found") diff --git a/backend/src/chitai/controllers/publisher.py b/backend/src/chitai/controllers/publisher.py index c940e06..d15b445 100644 --- a/backend/src/chitai/controllers/publisher.py +++ b/backend/src/chitai/controllers/publisher.py @@ -4,7 +4,7 @@ from typing import Annotated # Third-party libraries -from litestar import Controller, post, get, patch, delete +from litestar import Controller, get from litestar.params import Dependency from advanced_alchemy.extensions.litestar.providers import create_service_dependencies from advanced_alchemy.service.pagination import OffsetPagination diff --git a/backend/src/chitai/controllers/shelf.py b/backend/src/chitai/controllers/shelf.py index cefdd62..bc5e54b 100644 --- a/backend/src/chitai/controllers/shelf.py +++ b/backend/src/chitai/controllers/shelf.py @@ -70,7 +70,9 @@ class BookshelfController(Controller): filters.append(CollectionFilter("library_id", values=libraries)) filters.append(m.BookList.user_id == current_user.id) - results, total = await shelf_service.list_and_count(*filters, load=[selectinload(m.BookList.book_links)]) + results, total = await shelf_service.list_and_count( + *filters, load=[selectinload(m.BookList.book_links)] + ) return shelf_service.to_schema(results, total, filters, schema_type=ShelfRead) @post() diff --git a/backend/src/chitai/controllers/tag.py b/backend/src/chitai/controllers/tag.py index 60be45f..b40353d 100644 --- a/backend/src/chitai/controllers/tag.py +++ b/backend/src/chitai/controllers/tag.py @@ -4,7 +4,7 @@ from typing import Annotated # Third-party libraries -from litestar import Controller, post, get, patch, delete +from litestar import Controller, get from litestar.params import Dependency from advanced_alchemy.extensions.litestar.providers import create_service_dependencies from advanced_alchemy.service.pagination import OffsetPagination diff --git a/backend/src/chitai/database/models/book_list.py b/backend/src/chitai/database/models/book_list.py index d2d62cd..4ab2b85 100644 --- a/backend/src/chitai/database/models/book_list.py +++ b/backend/src/chitai/database/models/book_list.py @@ -34,7 +34,7 @@ class BookList(BigIntAuditBase): return len(self.book_links) if self.book_links else 0 except Exception: return None - + class BookListLink(BigIntBase): __tablename__ = "book_list_links" diff --git a/backend/src/chitai/database/models/book_progress.py b/backend/src/chitai/database/models/book_progress.py index 580fab2..8d7f74a 100644 --- a/backend/src/chitai/database/models/book_progress.py +++ b/backend/src/chitai/database/models/book_progress.py @@ -1,7 +1,6 @@ from typing import Optional from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy.orm import relationship from advanced_alchemy.base import BigIntAuditBase @@ -20,5 +19,5 @@ class BookProgress(BigIntAuditBase): pdf_page: Mapped[Optional[int]] percentage: Mapped[float] completed: Mapped[Optional[bool]] - device: Mapped[Optional[str]] # Device that updated the progress + device: Mapped[Optional[str]] # Device that updated the progress device_id: Mapped[Optional[str]] diff --git a/backend/src/chitai/database/models/book_series.py b/backend/src/chitai/database/models/book_series.py index 63968b8..9af6996 100644 --- a/backend/src/chitai/database/models/book_series.py +++ b/backend/src/chitai/database/models/book_series.py @@ -1,12 +1,10 @@ from collections.abc import Hashable from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy import ColumnElement, ForeignKey +from sqlalchemy import ColumnElement from advanced_alchemy.base import BigIntAuditBase from advanced_alchemy.mixins import UniqueMixin -from .book import Book - class BookSeries(BigIntAuditBase, UniqueMixin): __tablename__ = "book_series" diff --git a/backend/src/chitai/database/models/kosync_device.py b/backend/src/chitai/database/models/kosync_device.py index 25a52f9..9ed9578 100644 --- a/backend/src/chitai/database/models/kosync_device.py +++ b/backend/src/chitai/database/models/kosync_device.py @@ -1,9 +1,10 @@ -from sqlalchemy import ColumnElement, ForeignKey +from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped from sqlalchemy.orm import mapped_column from advanced_alchemy.base import BigIntAuditBase + class KosyncDevice(BigIntAuditBase): __tablename__ = "devices" diff --git a/backend/src/chitai/database/models/library.py b/backend/src/chitai/database/models/library.py index 45b2b31..eff11c6 100644 --- a/backend/src/chitai/database/models/library.py +++ b/backend/src/chitai/database/models/library.py @@ -15,7 +15,7 @@ class Library(BigIntAuditBase, SlugKey): name: Mapped[str] = mapped_column(unique=True) root_path: Mapped[str] # Which structure to save the files in the filesystem (i.e {author_name}/{title}.{ext}) - path_template: Mapped[str] + path_template: Mapped[str] description: Mapped[Optional[str]] icon: Mapped[str] = mapped_column(default="library") read_only: Mapped[bool] = mapped_column(nullable=False, default=False) diff --git a/backend/src/chitai/exceptions/handlers.py b/backend/src/chitai/exceptions/handlers.py index b59b7f3..ca78b20 100644 --- a/backend/src/chitai/exceptions/handlers.py +++ b/backend/src/chitai/exceptions/handlers.py @@ -1,6 +1,4 @@ from litestar import Request, Response, MediaType -from litestar.exceptions import HTTPException -from litestar.status_codes import HTTP_404_NOT_FOUND from advanced_alchemy.exceptions import NotFoundError diff --git a/backend/src/chitai/middleware/basic_auth.py b/backend/src/chitai/middleware/basic_auth.py index e5d8df7..96835fc 100644 --- a/backend/src/chitai/middleware/basic_auth.py +++ b/backend/src/chitai/middleware/basic_auth.py @@ -1,9 +1,9 @@ from base64 import b64decode from chitai.services.user import UserService from litestar.middleware import ( - AbstractAuthenticationMiddleware, - AuthenticationResult, - DefineMiddleware + AbstractAuthenticationMiddleware, + AuthenticationResult, + DefineMiddleware, ) from litestar.connection import ASGIConnection from litestar.exceptions import NotAuthorizedException, PermissionDeniedException @@ -11,25 +11,30 @@ from chitai.config import settings class BasicAuthenticationMiddleware(AbstractAuthenticationMiddleware): - async def authenticate_request(self, connection: ASGIConnection) -> AuthenticationResult: - """Given a request, parse the header for Base64 encoded basic auth credentials. """ + async def authenticate_request( + self, connection: ASGIConnection + ) -> AuthenticationResult: + """Given a request, parse the header for Base64 encoded basic auth credentials.""" # retrieve the auth header auth_header = connection.headers.get("Authorization", None) if not auth_header: raise NotAuthorizedException() - - username, password = b64decode(auth_header.split("Basic ")[1]).decode().split(":") - + username, password = ( + b64decode(auth_header.split("Basic ")[1]).decode().split(":") + ) + try: - db_session = settings.alchemy_config.provide_session(connection.app.state, connection.scope) + db_session = settings.alchemy_config.provide_session( + connection.app.state, connection.scope + ) user_service = UserService(db_session) user = await user_service.authenticate(username, password) return AuthenticationResult(user=user, auth=None) except PermissionDeniedException: raise NotAuthorizedException() - - -basic_auth_mw = DefineMiddleware(BasicAuthenticationMiddleware) \ No newline at end of file + + +basic_auth_mw = DefineMiddleware(BasicAuthenticationMiddleware) diff --git a/backend/src/chitai/middleware/kosync_auth.py b/backend/src/chitai/middleware/kosync_auth.py index 2e98de6..c8745ed 100644 --- a/backend/src/chitai/middleware/kosync_auth.py +++ b/backend/src/chitai/middleware/kosync_auth.py @@ -1,9 +1,9 @@ from chitai.services.user import UserService from chitai.services.kosync_device import KosyncDeviceService from litestar.middleware import ( - AbstractAuthenticationMiddleware, - AuthenticationResult, - DefineMiddleware + AbstractAuthenticationMiddleware, + AuthenticationResult, + DefineMiddleware, ) from litestar.connection import ASGIConnection from litestar.exceptions import NotAuthorizedException, PermissionDeniedException @@ -11,19 +11,23 @@ from chitai.config import settings class KosyncAuthenticationMiddleware(AbstractAuthenticationMiddleware): - async def authenticate_request(self, connection: ASGIConnection) -> AuthenticationResult: - """Given a request, parse the header for Base64 encoded basic auth credentials. """ + async def authenticate_request( + self, connection: ASGIConnection + ) -> AuthenticationResult: + """Given a request, parse the header for Base64 encoded basic auth credentials.""" # retrieve the auth header api_key = connection.headers.get("X-AUTH-USER", None) if not api_key: raise NotAuthorizedException() - + try: - db_session = settings.alchemy_config.provide_session(connection.app.state, connection.scope) + db_session = settings.alchemy_config.provide_session( + connection.app.state, connection.scope + ) user_service = UserService(db_session) device_service = KosyncDeviceService(db_session) - + device = await device_service.get_by_api_key(api_key) user = await user_service.get(device.user_id) @@ -32,6 +36,6 @@ class KosyncAuthenticationMiddleware(AbstractAuthenticationMiddleware): except PermissionDeniedException as exc: print(exc) raise NotAuthorizedException() - - -kosync_api_key_auth = DefineMiddleware(KosyncAuthenticationMiddleware) \ No newline at end of file + + +kosync_api_key_auth = DefineMiddleware(KosyncAuthenticationMiddleware) diff --git a/backend/src/chitai/schemas/book.py b/backend/src/chitai/schemas/book.py index 17ea732..a1d940a 100644 --- a/backend/src/chitai/schemas/book.py +++ b/backend/src/chitai/schemas/book.py @@ -273,5 +273,3 @@ class BookProgressCreate(BaseModel): completed: bool | None = None device_type: str | None = None device_id: str | None = None - - diff --git a/backend/src/chitai/schemas/library.py b/backend/src/chitai/schemas/library.py index 55225f5..d7c596c 100644 --- a/backend/src/chitai/schemas/library.py +++ b/backend/src/chitai/schemas/library.py @@ -1,8 +1,8 @@ -from pathlib import Path from typing import Annotated from pydantic import BaseModel, ConfigDict, Field, SkipValidation, computed_field from litestar.datastructures import UploadFile -from advanced_alchemy.utils.text import slugify +from advanced_alchemy.utils.text import slugify + class LibraryCreate(BaseModel): name: Annotated[str, Field(min_length=1)] @@ -17,6 +17,7 @@ class LibraryCreate(BaseModel): def slug(self) -> str: return slugify(self.name) + class LibraryRead(BaseModel): id: int name: str diff --git a/backend/src/chitai/schemas/shelf.py b/backend/src/chitai/schemas/shelf.py index 75c233f..da6d2b8 100644 --- a/backend/src/chitai/schemas/shelf.py +++ b/backend/src/chitai/schemas/shelf.py @@ -1,11 +1,11 @@ -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, Field, field_validator class ShelfRead(BaseModel): id: int title: str library_id: int | None = None - total: int | None = None # Number of books in the shelf + total: int | None = None # Number of books in the shelf class ShelfCreate(BaseModel): diff --git a/backend/src/chitai/services/book.py b/backend/src/chitai/services/book.py index b0f24ac..42b4481 100644 --- a/backend/src/chitai/services/book.py +++ b/backend/src/chitai/services/book.py @@ -19,7 +19,7 @@ from advanced_alchemy.service import ( SQLAlchemyAsyncRepositoryService, ModelDictT, is_dict, - schema_dump + schema_dump, ) from advanced_alchemy.repository import SQLAlchemyAsyncRepository from advanced_alchemy.filters import CollectionFilter @@ -349,7 +349,9 @@ def _series_position(data: Any) -> tuple[str, str | None]: return normalize_title(series), (position or "").strip() or None -def _is_different_volume(left: tuple[str, str | None], right: tuple[str, str | None]) -> bool: +def _is_different_volume( + left: tuple[str, str | None], right: tuple[str, str | None] +) -> bool: """ Whether two books are numbered entries of one series, and not the same entry. @@ -464,8 +466,6 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]): repository_type = Repo - - async def create_book( self, data: ModelDictT[Book], @@ -647,7 +647,10 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]): return [] statement = ( - select(Book).where(or_(*conditions)).options(*self._MATCH_LOADS).order_by(Book.id) + select(Book) + .where(or_(*conditions)) + .options(*self._MATCH_LOADS) + .order_by(Book.id) ) if exclude_book_id is not None: @@ -724,7 +727,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]): ValueError: If fewer than two distinct books were named, one of them does not exist, or they do not all belong to one library. """ - merged_ids = [book_id for book_id in dict.fromkeys(merged_ids) if book_id != survivor_id] + merged_ids = [ + book_id for book_id in dict.fromkeys(merged_ids) if book_id != survivor_id + ] if not merged_ids: raise ValueError("A merge needs at least two different books") @@ -848,12 +853,16 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]): # Reading progress is per user, and the furthest one is the true answer for a # reader who has been through the EPUB and not the PDF. rows = ( - await session.execute( - select(BookProgress) - .where(BookProgress.book_id.in_([survivor_id, *merged_ids])) - .order_by(BookProgress.percentage.desc()) + ( + await session.execute( + select(BookProgress) + .where(BookProgress.book_id.in_([survivor_id, *merged_ids])) + .order_by(BookProgress.percentage.desc()) + ) ) - ).scalars().all() + .scalars() + .all() + ) furthest: dict[int, BookProgress] = {} for progress in rows: @@ -902,14 +911,19 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]): # and only one of them however many books offered it. held_names = select(Identifier.name).where(Identifier.book_id == survivor_id) incoming = ( - await session.execute( - select(Identifier) - .where( - Identifier.book_id.in_(merged_ids), Identifier.name.notin_(held_names) + ( + await session.execute( + select(Identifier) + .where( + Identifier.book_id.in_(merged_ids), + Identifier.name.notin_(held_names), + ) + .order_by(Identifier.book_id, Identifier.id) ) - .order_by(Identifier.book_id, Identifier.id) ) - ).scalars().all() + .scalars() + .all() + ) taken: set[str] = set() for identifier in incoming: @@ -932,15 +946,19 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]): merged = set(merged_ids) rows = ( - await session.execute( - select(DuplicateDismissal).where( - or_( - DuplicateDismissal.book_a_id.in_(merged_ids), - DuplicateDismissal.book_b_id.in_(merged_ids), + ( + await session.execute( + select(DuplicateDismissal).where( + or_( + DuplicateDismissal.book_a_id.in_(merged_ids), + DuplicateDismissal.book_b_id.in_(merged_ids), + ) ) ) ) - ).scalars().all() + .scalars() + .all() + ) existing = await self._dismissed_pairs() doomed: list[int] = [] @@ -1025,7 +1043,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]): # copies meet as long as they agree on any one of them. for title in titles: for author in authors: - buckets[(MATCHED_ON_TITLE_AUTHOR, f"{title}\x00{author}")].append(book_id) + buckets[(MATCHED_ON_TITLE_AUTHOR, f"{title}\x00{author}")].append( + book_id + ) dismissed = await self._dismissed_pairs() series = {book_id: _series_position(book) for book_id, book in books.items()} @@ -1071,8 +1091,14 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]): raise ValueError("A book cannot be dismissed against itself") found = ( - await self.repository.session.execute(select(Book.id).where(Book.id.in_(pair))) - ).scalars().all() + ( + await self.repository.session.execute( + select(Book.id).where(Book.id.in_(pair)) + ) + ) + .scalars() + .all() + ) if len(set(found)) != 2: raise ValueError("No such book") @@ -1305,7 +1331,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]): **kwargs, ) result.books.append(book) - await self._record_possible_duplicates(result.possible_duplicates, book, library) + await self._record_possible_duplicates( + result.possible_duplicates, book, library + ) return result @@ -1344,7 +1372,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]): consume_path: Path, library: Library, allow_duplicates: bool = False, - **kwargs + **kwargs, ) -> ImportResult: """ Import files that are already on disk, from the consume directory. @@ -1368,7 +1396,6 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]): result = ImportResult() file_groups: dict[Path, list[Path]] = defaultdict(list) - for file_path in file_paths: rel_path = file_path.relative_to(consume_path) parent_rel = rel_path.parent @@ -1379,11 +1406,10 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]): group_key = rel_path # Use the file itself as the key else: group_key = parent_rel # Use parent directory as key - + # Add to appropriate group file_groups[group_key].append(file_path) - # For each grouping for group, files in file_groups.items(): # Fingerprinted before anything moves, since the paths are about to change. @@ -1405,7 +1431,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]): cleanup_empty_parent_directories(consume_path / group, consume_path) continue - data: dict[str, Any] = {'files': accepted} + data: dict[str, Any] = {"files": accepted} await self._parse_metadata_from_files(data, root_path=consume_path) await self._save_cover_image(data) @@ -1446,7 +1472,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]): book = await super().create(data) result.books.append(book) - await self._record_possible_duplicates(result.possible_duplicates, book, library) + await self._record_possible_duplicates( + result.possible_duplicates, book, library + ) await self.repository.session.commit() @@ -1554,17 +1582,11 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]): # What the catalogue claims and what is on disk can disagree: Calibre keeps the # row when a file is moved away behind its back. present = [ - file.path - for file in entry.files - if await aios.path.isfile(file.path) + file.path for file in entry.files if await aios.path.isfile(file.path) ] if not present: - reason = ( - "no files on disk" - if entry.files - else "no files in the catalogue" - ) + reason = "no files on disk" if entry.files else "no files in the catalogue" result.skipped.append( UnimportedBook( calibre_id=entry.calibre_id, title=entry.title, reason=reason @@ -1918,9 +1940,13 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]): # TODO: Move only the files associated with the book instead of the whole directory await move_dir_contents(book.path, updated_path) data["path"] = str(updated_path) - cleanup_empty_parent_directories(Path(book.path), Path(library.root_path)) + cleanup_empty_parent_directories( + Path(book.path), Path(library.root_path) + ) - return await super().update(data, item_id=book_id, execution_options={"populate_existing": True}) + return await super().update( + data, item_id=book_id, execution_options={"populate_existing": True} + ) async def add_files( self, @@ -1979,7 +2005,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]): data["files"] = files new_files = await self._save_book_files(library, data, fingerprints) book.files.extend(new_files) - await self.update_book(book.id, {"files": [file for file in book.files]}, library) + await self.update_book( + book.id, {"files": [file for file in book.files]}, library + ) @staticmethod async def _restore_file( @@ -2067,22 +2095,24 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]): model_data = await self._populate_with_unique_relationships(data) return model_data - + def _preprocess_book_data(self, data: dict) -> dict: """Transform API input format to model format.""" if not isinstance(data, dict): return data - + # Transform dict identifiers to list of Identifier objects if "identifiers" in data and isinstance(data["identifiers"], dict): data["identifiers"] = [ Identifier(name=key, value=val) for key, val in data["identifiers"].items() ] - + return data - async def _populate_with_unique_relationships(self, data: ModelDictT[Book]) -> ModelDictT[Book]: + async def _populate_with_unique_relationships( + self, data: ModelDictT[Book] + ) -> ModelDictT[Book]: """ Ensure relationship entities (authors, series, tags, etc.) are unique in the database. @@ -2307,7 +2337,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]): data["files"] = file_metadata return data["files"] - async def _parse_metadata_from_files(self, data: dict, root_path: Path | None = None) -> dict: + async def _parse_metadata_from_files( + self, data: dict, root_path: Path | None = None + ) -> dict: """ Extract metadata (title, author, etc.) from book files. @@ -2319,7 +2351,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]): Returns: The data with extracted metadata populated in empty fields. """ - extracted_metadata = await MetadataExtractor.extract_metadata(data["files"], root_path) + extracted_metadata = await MetadataExtractor.extract_metadata( + data["files"], root_path + ) # Add missing fields and update empty (falsey) fields with extracted metadata for attr in extracted_metadata.keys(): diff --git a/backend/src/chitai/services/bookshelf.py b/backend/src/chitai/services/bookshelf.py index 3a2ef1e..4072345 100644 --- a/backend/src/chitai/services/bookshelf.py +++ b/backend/src/chitai/services/bookshelf.py @@ -1,13 +1,8 @@ # src/chitai/services/bookshelf.py # Third-party libraries -from typing import Any, Sequence -from advanced_alchemy.exceptions import ErrorMessages -from advanced_alchemy.filters import StatementFilter from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService from advanced_alchemy.repository import SQLAlchemyAsyncRepository -from advanced_alchemy.utils.dataclass import Empty, EmptyType -from sqlalchemy import ColumnElement, Select, delete # Local imports from chitai.database.models.book_list import BookList, BookListLink diff --git a/backend/src/chitai/services/calibre.py b/backend/src/chitai/services/calibre.py index 37620e5..4cbc3b4 100644 --- a/backend/src/chitai/services/calibre.py +++ b/backend/src/chitai/services/calibre.py @@ -297,7 +297,9 @@ class CalibreLibrary: async def count(self) -> int: """How many books the catalogue holds, without reading any of them.""" - rows = await self._in_thread(lambda: self._execute("SELECT count(*) FROM books")) + rows = await self._in_thread( + lambda: self._execute("SELECT count(*) FROM books") + ) return int(rows[0][0]) async def books(self) -> list[CalibreBook]: @@ -393,7 +395,9 @@ class CalibreLibrary: calibre_id=book_id, uuid=str(uuid or ""), title=str(title or ""), - authors=[unescape_author(name) for name in authors.get(book_id, [])], + authors=[ + unescape_author(name) for name in authors.get(book_id, []) + ], description=strip_html(descriptions.get(book_id)), published_date=parse_date(pubdate), series=in_series, @@ -510,8 +514,19 @@ class _TextExtractor(HTMLParser): # a description of three paragraphs comes out as one run-on sentence. _BREAKS = frozenset( { - "p", "br", "div", "li", "tr", "blockquote", "hr", - "h1", "h2", "h3", "h4", "h5", "h6", + "p", + "br", + "div", + "li", + "tr", + "blockquote", + "hr", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", } ) diff --git a/backend/src/chitai/services/consume.py b/backend/src/chitai/services/consume.py index 8b79100..37f415f 100644 --- a/backend/src/chitai/services/consume.py +++ b/backend/src/chitai/services/consume.py @@ -4,17 +4,23 @@ from collections import defaultdict from chitai.config import settings from chitai.database.models.library import Library from chitai.services import BookService, LibraryService -from chitai.services.metadata_extractor import Extractor from chitai.services.utils import create_directory from watchfiles import awatch, Change + class ConsumeDirectoryWatcher: """Watches a directory and batch processes files by their relative path.""" - - def __init__(self, watch_path: str, library_service: LibraryService, book_service: BookService, batch_delay: float = 3.0): + + def __init__( + self, + watch_path: str, + library_service: LibraryService, + book_service: BookService, + batch_delay: float = 3.0, + ): """ Initialize the file watcher. - + Args: watch_path: Directory path to watch batch_delay: Seconds to wait before processing a batch @@ -41,9 +47,9 @@ class ConsumeDirectoryWatcher: for change_type, file_path in changes: if change_type != Change.added: continue - + file_path = Path(file_path) - + # If a directory was added, scan it for existing files if file_path.is_dir(): print(f"Directory added: {file_path}") @@ -51,28 +57,28 @@ class ConsumeDirectoryWatcher: else: print(f"File added: {file_path}") await self._handle_file_added(file_path) - + except asyncio.CancelledError: print("File watcher stopped") # Wait for any pending processing tasks if self._processing_tasks: await asyncio.gather(*self._processing_tasks, return_exceptions=True) raise - + async def _handle_file_added(self, file_path: Path): """Handle a single file being added.""" # Get relative path from watch directory - + rel_path = file_path.relative_to(self.watch_path) parent_rel = rel_path.parent library = parent_rel.parts[0] # Add to appropriate group self.file_groups[library].add(file_path) - + # Schedule batch processing for this group self._schedule_batch_processing(library) - + async def _handle_directory_added(self, dir_path: Path): """Handle a directory being added - scan it for existing files.""" try: @@ -83,35 +89,34 @@ class ConsumeDirectoryWatcher: await self._handle_file_added(file_path) except Exception as e: print(f"Error scanning directory {dir_path}: {e}") - + def _schedule_batch_processing(self, library_slug: str): """Schedule batch processing for a specific path group.""" # Create a task to process this group after a delay task = asyncio.create_task(self._delayed_batch_process(library_slug)) self._processing_tasks.add(task) task.add_done_callback(self._processing_tasks.discard) - + async def _delayed_batch_process(self, library_slug: str): """Wait for batch delay, then process accumulated files.""" await asyncio.sleep(self.batch_delay) - + # Get and clear the file list for this path if library_slug not in self.file_groups: return - + files_to_process = self.file_groups[library_slug].copy() self.file_groups[library_slug].clear() - + if not files_to_process: return - + print(f"Batch processing {len(files_to_process)} files from {library_slug}") await self._process_batch(files_to_process, library_slug) - + async def _process_batch(self, file_paths: set[Path], library_slug: str): """Process a batch of files.""" try: - result = await self.book_service.create_many_from_existing_files( list(file_paths), self.watch_path / Path(library_slug), @@ -138,10 +143,9 @@ class ConsumeDirectoryWatcher: f"already be in the library as: {names}" ) - except Exception as e: print(f"Error processing batch: {e}") raise e - + async def _get_library(self, slug: str) -> Library: return await self.library_service.get_one(Library.slug == slug) diff --git a/backend/src/chitai/services/dependencies.py b/backend/src/chitai/services/dependencies.py index ead76e3..3cef000 100644 --- a/backend/src/chitai/services/dependencies.py +++ b/backend/src/chitai/services/dependencies.py @@ -2,7 +2,7 @@ # Standard library from __future__ import annotations -from typing import Any, AsyncGenerator, Callable, NotRequired, Optional +from typing import Any, AsyncGenerator, Optional # Third-party libraries from advanced_alchemy.extensions.litestar.providers import ( @@ -13,7 +13,6 @@ from advanced_alchemy.extensions.litestar.providers import ( ) from advanced_alchemy.exceptions import NotFoundError from advanced_alchemy.filters import CollectionFilter, StatementFilter -from advanced_alchemy.service import FilterTypeT from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession from litestar import Request @@ -25,7 +24,6 @@ from litestar.di import Provide from advanced_alchemy.extensions.litestar.providers import create_filter_dependencies # Local imports -from chitai import schemas as s from chitai.database import models as m from chitai.services import ( UserService, @@ -154,7 +152,6 @@ def create_book_filter_dependencies( # OVERRIDE: Custom search filter with trigram search if config.get("search"): - search_fields = config.get("search") def provide_trigram_search_filter( search_string: str | None = Parameter( @@ -370,6 +367,7 @@ def provide_optional_user(request: Request[m.User, Token, Any]) -> m.User | None return None + async def provide_user_via_basic_auth(request: Request[m.User, None, Any]) -> m.User: return request.user @@ -381,4 +379,3 @@ async def provide_user_via_kosync_auth(request: Request[m.User, None, Any]) -> m provide_kosync_device_service = create_service_provider(KosyncDeviceService) provide_kosync_progress_service = create_service_provider(KosyncProgressService) - \ No newline at end of file diff --git a/backend/src/chitai/services/filesystem_library.py b/backend/src/chitai/services/filesystem_library.py index a3a2551..dd5aa2f 100644 --- a/backend/src/chitai/services/filesystem_library.py +++ b/backend/src/chitai/services/filesystem_library.py @@ -4,9 +4,7 @@ from pathlib import Path import re from jinja2 import Template -from advanced_alchemy.service import ModelDictT -import chitai.database.models as m # TODO: Replace Jinja2 templates with a simpler custom templating system. # Current Jinja2 implementation is overly complex for basic path generation. diff --git a/backend/src/chitai/services/filters/author.py b/backend/src/chitai/services/filters/author.py index 13ca0a7..093e861 100644 --- a/backend/src/chitai/services/filters/author.py +++ b/backend/src/chitai/services/filters/author.py @@ -1,4 +1,4 @@ -from typing import Any, Optional +from typing import Optional from dataclasses import dataclass from advanced_alchemy.filters import ( diff --git a/backend/src/chitai/services/filters/book.py b/backend/src/chitai/services/filters/book.py index 172f2de..0f9273f 100644 --- a/backend/src/chitai/services/filters/book.py +++ b/backend/src/chitai/services/filters/book.py @@ -3,11 +3,10 @@ from typing import Any, Optional from dataclasses import dataclass, field from sqlalchemy.orm import aliased -from sqlalchemy import Select, and_, desc, func, or_, text +from sqlalchemy import Select, and_, func, or_, text from advanced_alchemy.filters import ( StatementTypeT, StatementFilter, - CollectionFilter, ModelT, ) @@ -135,7 +134,7 @@ class ProgressFilter(StatementFilter): status_conditions.append( and_( or_( - m.BookProgress.completed == False, + m.BookProgress.completed.is_(False), m.BookProgress.completed.is_(None), ), m.BookProgress.percentage > 0, @@ -143,7 +142,7 @@ class ProgressFilter(StatementFilter): ) if ProgressStatus.READ in self.statuses: - status_conditions.append(m.BookProgress.completed == True) + status_conditions.append(m.BookProgress.completed.is_(True)) if ProgressStatus.UNREAD in self.statuses: status_conditions.append(m.BookProgress.id.is_(None)) @@ -154,6 +153,7 @@ class ProgressFilter(StatementFilter): @dataclass class FileFilter(StatementFilter): """Filter books that are related to the given files.""" + file_ids: list[int] def append_to_statement( @@ -165,17 +165,21 @@ class FileFilter(StatementFilter): return super().append_to_statement(statement, model, *args, **kwargs) + @dataclass class FileHashFilter(StatementFilter): file_hashes: list[str] - def append_to_statement(self, statement: StatementTypeT, model: type[ModelT], *args, **kwargs) -> StatementTypeT: + def append_to_statement( + self, statement: StatementTypeT, model: type[ModelT], *args, **kwargs + ) -> StatementTypeT: statement = statement.where( m.Book.files.any(m.FileMetadata.hash.in_(self.file_hashes)) ) return super().append_to_statement(statement, model, *args, **kwargs) + @dataclass class CustomOrderBy(StatementFilter): """Order by filter with support for 'random' and 'last accessed' orderings.""" diff --git a/backend/src/chitai/services/kosync_device.py b/backend/src/chitai/services/kosync_device.py index c77ac61..921bbc5 100644 --- a/backend/src/chitai/services/kosync_device.py +++ b/backend/src/chitai/services/kosync_device.py @@ -1,16 +1,21 @@ from __future__ import annotations import secrets from chitai.database.models.kosync_device import KosyncDevice -from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService, ModelDictT, schema_dump +from advanced_alchemy.service import ( + SQLAlchemyAsyncRepositoryService, + ModelDictT, + schema_dump, +) from advanced_alchemy.repository import SQLAlchemyAsyncRepository + class KosyncDeviceService(SQLAlchemyAsyncRepositoryService[KosyncDevice]): """Service for managing KOReader devices.""" API_KEY_LENGTH_IN_BYTES = 8 class Repo(SQLAlchemyAsyncRepository[KosyncDevice]): - """ Repository for KosyncDevice entities.""" + """Repository for KosyncDevice entities.""" model_type = KosyncDevice @@ -18,18 +23,17 @@ class KosyncDeviceService(SQLAlchemyAsyncRepositoryService[KosyncDevice]): async def create(self, data: ModelDictT[KosyncDevice], **kwargs) -> KosyncDevice: data = schema_dump(data) - data['api_key'] = self._generate_api_key() + data["api_key"] = self._generate_api_key() return await super().create(data, **kwargs) - + async def get_by_api_key(self, api_key: str) -> KosyncDevice: return await self.get_one(KosyncDevice.api_key == api_key) - - async def regenerate_api_key(self, device_id: int) -> KosyncDevice: + + async def regenerate_api_key(self, device_id: int) -> KosyncDevice: device = await self.get(device_id) api_key = self._generate_api_key() device.api_key = api_key return await self.update(device) - def _generate_api_key(self) -> str: return secrets.token_hex(self.API_KEY_LENGTH_IN_BYTES) diff --git a/backend/src/chitai/services/kosync_progress.py b/backend/src/chitai/services/kosync_progress.py index 24ac1be..8af2622 100644 --- a/backend/src/chitai/services/kosync_progress.py +++ b/backend/src/chitai/services/kosync_progress.py @@ -16,7 +16,9 @@ class KosyncProgressService(SQLAlchemyAsyncRepositoryService[KosyncProgress]): repository_type = Repo - async def get_by_document_hash(self, user_id: int, document: str) -> KosyncProgress | None: + async def get_by_document_hash( + self, user_id: int, document: str + ) -> KosyncProgress | None: """Get progress for a specific document and user.""" return await self.get_one_or_none( KosyncProgress.user_id == user_id, diff --git a/backend/src/chitai/services/library.py b/backend/src/chitai/services/library.py index 10cdeeb..8f856bd 100644 --- a/backend/src/chitai/services/library.py +++ b/backend/src/chitai/services/library.py @@ -5,7 +5,6 @@ from pathlib import Path from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService from advanced_alchemy.repository import SQLAlchemyAsyncRepository from advanced_alchemy import service -from advanced_alchemy.utils.text import slugify # Local imports from chitai.database.models.library import Library @@ -18,6 +17,7 @@ from chitai.services.utils import ( from chitai.config import settings + class LibraryService(SQLAlchemyAsyncRepositoryService[Library]): """Service for managing libraries and their configuration.""" @@ -48,7 +48,7 @@ class LibraryService(SQLAlchemyAsyncRepositoryService[Library]): """ # TODO: What if a library root_path is a child of an existing library? - if existing := await self.list(Library.root_path == library.root_path): + if await self.list(Library.root_path == library.root_path): raise ValueError(f"Library already exists at '{library.root_path}'") if library.read_only: @@ -57,8 +57,7 @@ class LibraryService(SQLAlchemyAsyncRepositoryService[Library]): raise DirectoryDoesNotExist( f"Root directory '{library.root_path}' must exist for a read-only library" ) - - + # TODO: Verify the read-only library has read permissions created_library = await super().create( service.schema_dump(library, exclude_unset=False), **kwargs @@ -71,9 +70,6 @@ class LibraryService(SQLAlchemyAsyncRepositoryService[Library]): await create_directory(Path(settings.consume_path) / Path(library.slug)) return created_library - - - # TODO: Implement library deletion and optional file deletion async def delete( diff --git a/backend/src/chitai/services/metadata_extractor.py b/backend/src/chitai/services/metadata_extractor.py index 1e6794f..7130675 100644 --- a/backend/src/chitai/services/metadata_extractor.py +++ b/backend/src/chitai/services/metadata_extractor.py @@ -103,8 +103,18 @@ def parse_identifier(value: str, scheme: str | None = None) -> tuple[str, str] | # Numbered editions, in the forms covers and catalogue records actually use: # "3rd Edition", "2E", "4e", "8_e", "/6e", "(2nd edition)", "Third International Edition". _ORDINAL_WORDS = { - "first": 1, "second": 2, "third": 3, "fourth": 4, "fifth": 5, "sixth": 6, - "seventh": 7, "eighth": 8, "ninth": 9, "tenth": 10, "eleventh": 11, "twelfth": 12, + "first": 1, + "second": 2, + "third": 3, + "fourth": 4, + "fifth": 5, + "sixth": 6, + "seventh": 7, + "eighth": 8, + "ninth": 9, + "tenth": 10, + "eleventh": 11, + "twelfth": 12, } # Words that sit between the number and "Edition" and belong to the same statement. @@ -161,7 +171,9 @@ def split_edition(title: str | None) -> tuple[str | None, int | None]: stripped = _EDITION.sub(" ", title) stripped = re.sub(r"\s{2,}", " ", stripped) - stripped = re.sub(r"\s+([,;:.!?])", r"\1", stripped) # "Works : What" → "Works: What" + stripped = re.sub( + r"\s+([,;:.!?])", r"\1", stripped + ) # "Works : What" → "Works: What" stripped = stripped.strip(" ,;:-–—/") # A title that is only an edition statement is not improved by having none. @@ -178,7 +190,9 @@ class FileExtractor(Protocol): ) -> dict[str, Any]: ... @classmethod - async def extract_text(cls, input: UploadFile | BinaryIO | bytes | Path | str) -> str: ... + async def extract_text( + cls, input: UploadFile | BinaryIO | bytes | Path | str + ) -> str: ... class Extractor: @@ -187,7 +201,9 @@ class Extractor: format_priorities = {"epub": 1, "pdf": 2} @classmethod - async def extract_metadata(cls, files: list[UploadFile] | list[Path], root_path: Path | None = None) -> dict[str, Any]: + async def extract_metadata( + cls, files: list[UploadFile] | list[Path], root_path: Path | None = None + ) -> dict[str, Any]: metadata = {} # Sort based on file priority @@ -235,7 +251,7 @@ class Extractor: metadata = FilepathExtractor.extract_metadata(files[0], root_path) | metadata # format the title - if metadata.get('title', None): + if metadata.get("title", None): # Before the subtitle split, so the edition cannot be mistaken for one: # "How Linux Works, 3rd Edition: What Every Superuser Should Know" has to # lose the edition first for the colon count to mean anything. @@ -282,8 +298,8 @@ class Extractor: file_ext = get_file_extension(filename) if file_ext is None: - return float('inf') - + return float("inf") + return Extractor.format_priorities.get(file_ext, float("inf")) @classmethod @@ -618,8 +634,10 @@ class FilepathExtractor(FileExtractor): """Extracts metadata from the filepath.""" @classmethod - def extract_metadata(cls, input: UploadFile | Path | str, root_path: Path | None = None) -> dict[str, Any]: - + def extract_metadata( + cls, input: UploadFile | Path | str, root_path: Path | None = None + ) -> dict[str, Any]: + if isinstance(input, UploadFile): path = Path(input.filename).parent else: @@ -627,33 +645,34 @@ class FilepathExtractor(FileExtractor): if root_path: path = path.relative_to(root_path) - + parts = path.parts metadata: dict[str, str | None] = {} - + if len(parts) == 3: # Format: Author/Series/Part - Title/filename - metadata['author'] = parts[0] - + metadata["author"] = parts[0] + # Extract part number and title from directory name (parts[2]) dirname = parts[2] - match = re.match(r'^([\d.]+)\s*-\s*(.+)$', dirname) - + match = re.match(r"^([\d.]+)\s*-\s*(.+)$", dirname) + if match: - metadata['series_position'] = match.group(1) # Keep as string - metadata['series'] = parts[1] - metadata['title'] = match.group(2).strip() + metadata["series_position"] = match.group(1) # Keep as string + metadata["series"] = parts[1] + metadata["title"] = match.group(2).strip() else: - metadata['series'] = parts[1] - metadata['title'] = path.stem - + metadata["series"] = parts[1] + metadata["title"] = path.stem + elif len(parts) == 2: # Format: Author/Title - metadata['author'] = parts[0] - metadata['title'] = path.stem # Remove extension - + metadata["author"] = parts[0] + metadata["title"] = path.stem # Remove extension + return metadata + class FilenameExtractor(FileExtractor): """Extracts metadata from the filename.""" diff --git a/backend/src/chitai/services/opds/models.py b/backend/src/chitai/services/opds/models.py index a998980..bff06e4 100644 --- a/backend/src/chitai/services/opds/models.py +++ b/backend/src/chitai/services/opds/models.py @@ -4,43 +4,57 @@ from typing import Any, Literal, Optional, Sequence from pydantic import BaseModel, Field from datetime import datetime + class LinkTypes(StrEnum): NAVIGATION = "application/atom+xml;profile=opds-catalog;kind=navigation" ACQUISITION = "application/atom+xml;profile=opds-catalog;kind=acquisition" OPEN_SEARCH = "application/opensearchdescription+xml" + class AcquisitionRelations(StrEnum): _BASE = "http://opds-spec.org/acquisition" - - ACQUISITION = _BASE # A generic relation that indicates that the entry may be retrieved - OPEN_ACCESS = f"{_BASE}/open-access" # Entry may be retrieved without any requirement - BORROW = f"{_BASE}/borrow" # Entry may be retrieved as part of a lending transaction - BUY = f"{_BASE}/buy" # Entry may be retrieved as part of a purchase - SAMPLE = f"{_BASE}/sample" # Subset of the entry may be retrieved - PREVIEW = f"{_BASE}/preview" # Subset of the entry may be retrieved - SUBSCRIBE = f"{_BASE}/subscribe" # Entry my be retrieved as a part of a subscription + + ACQUISITION = ( + _BASE # A generic relation that indicates that the entry may be retrieved + ) + OPEN_ACCESS = ( + f"{_BASE}/open-access" # Entry may be retrieved without any requirement + ) + BORROW = ( + f"{_BASE}/borrow" # Entry may be retrieved as part of a lending transaction + ) + BUY = f"{_BASE}/buy" # Entry may be retrieved as part of a purchase + SAMPLE = f"{_BASE}/sample" # Subset of the entry may be retrieved + PREVIEW = f"{_BASE}/preview" # Subset of the entry may be retrieved + SUBSCRIBE = ( + f"{_BASE}/subscribe" # Entry my be retrieved as a part of a subscription + ) + class NavigationRelations(StrEnum): _BASE = "" + class LinkRelations(StrEnum): - """ Link types for OPDSv1.2 related resources + """Link types for OPDSv1.2 related resources https://specs.opds.io/opds-1.2.html#6-additional-link-relations """ _BASE = "http://opds-spec.org" - START = "start" # The OPDS catalog root - SUBSECTION = "subsection" # an OPDS feed not better described by any of the below relations - SHELF = f"{_BASE}/shelf" # Entries acquired by the euser - SUBSCRIPTIONS = f"{_BASE}/subscriptions" # Entries available with users's subscription - NEW = f"{_BASE}/sort/new" # Newest entries - POPULAR = f"{_BASE}/sort/popular" # Most popular entries - FEATURED = f"{_BASE}/featured" # Entries selected for promotion - RECOMMENDED = f"{_BASE}/recommended" # Entries recommended to the specific user - - + START = "start" # The OPDS catalog root + SUBSECTION = ( + "subsection" # an OPDS feed not better described by any of the below relations + ) + SHELF = f"{_BASE}/shelf" # Entries acquired by the euser + SUBSCRIPTIONS = ( + f"{_BASE}/subscriptions" # Entries available with users's subscription + ) + NEW = f"{_BASE}/sort/new" # Newest entries + POPULAR = f"{_BASE}/sort/popular" # Most popular entries + FEATURED = f"{_BASE}/featured" # Entries selected for promotion + RECOMMENDED = f"{_BASE}/recommended" # Entries recommended to the specific user class Feed(BaseModel): # OPDS Catalog root element @@ -117,9 +131,8 @@ class AcquisitionFeedLink(Link): class NavigationFeedLink(Link): - type: str = Field( - default=LinkTypes.NAVIGATION, serialization_alias="@type" - ) + type: str = Field(default=LinkTypes.NAVIGATION, serialization_alias="@type") + class Content(BaseModel): type: Literal["text"] = Field(default="text", serialization_alias="@type") @@ -170,9 +183,10 @@ class Entry(BaseModel): data = super().model_dump(**kwargs) return {"entry": data} + @dataclass class PaginationResult: next_link: Optional[Link] prev_link: Optional[Link] current_offset: int - total_count: int \ No newline at end of file + total_count: int diff --git a/backend/src/chitai/services/opds/opds.py b/backend/src/chitai/services/opds/opds.py index f70ad49..88710d9 100644 --- a/backend/src/chitai/services/opds/opds.py +++ b/backend/src/chitai/services/opds/opds.py @@ -1,4 +1,3 @@ - from typing import Any, Callable, Sequence from urllib.parse import quote_plus, urlencode from litestar import Request @@ -16,9 +15,10 @@ from .models import ( AcquisitionFeedLink, NavigationFeed, NavigationFeedLink, - PaginationResult + PaginationResult, ) + def get_opensearch_document(base_url: str = "/opds/search?") -> str: search = { "OpenSearchDescription": { @@ -120,19 +120,20 @@ def create_navigation_feed( pretty=True, ) + def create_library_navigation_feed(library: m.Library) -> str: entries = [ Entry( id=f"/opds/library/{library.id}/all-books", - title='All Books', + title="All Books", link=[ AcquisitionFeedLink( rel="subsection", href=f"/opds/acquisition?libraries={library.id}&paginated=1&pageSize=50&feed_title=AllBooks&feed_id=/opds/library/{library.id}/all-books", title="All Books", ) - ] + ], ), Entry( id=f"/opds/library/{library.id}/recently-added", @@ -141,9 +142,9 @@ def create_library_navigation_feed(library: m.Library) -> str: NavigationFeedLink( rel="http://opds-spec.org/sort/new", href=f"/opds/acquisition?libraries={library.id}&orderBy=created_at&pageSize=50&feed_title=RecentlyAdded&feed_id=/opds/library/{library.id}/recently-added", - title="Recently Added" + title="Recently Added", ) - ] + ], ), Entry( id=f"/opds/library/{library.id}/shelves", @@ -152,9 +153,9 @@ def create_library_navigation_feed(library: m.Library) -> str: NavigationFeedLink( rel="subsection", href=f"/opds/library/{library.id}/shelves?paginated=1&pageSize=10", - title="Bookshelves" + title="Bookshelves", ) - ] + ], ), Entry( id=f"/opds/library/{library.id}/tags", @@ -163,9 +164,9 @@ def create_library_navigation_feed(library: m.Library) -> str: NavigationFeedLink( rel="subsection", href=f"/opds/library/{library.id}/tags?paginated=1&pageSize=10", - title="Tags" + title="Tags", ) - ] + ], ), Entry( id=f"/opds/library/{library.id}/authors", @@ -174,9 +175,9 @@ def create_library_navigation_feed(library: m.Library) -> str: NavigationFeedLink( rel="subsection", href=f"/opds/library/{library.id}/authors?paginated=1&pageSize=10", - title="Authors" + title="Authors", ) - ] + ], ), Entry( id=f"/opds/library/{library.id}/publishers", @@ -185,34 +186,34 @@ def create_library_navigation_feed(library: m.Library) -> str: NavigationFeedLink( rel="subsection", href=f"/opds/library/{library.id}/publishers?paginated=1&pageSize=10", - title="Publishers" + title="Publishers", ) - ] + ], ), - ] feed = create_navigation_feed( - id=f'/library/{library.id}', + id=f"/library/{library.id}", title=library.name, - self_url=f'/opds/library/{library.id}', - links=[ - - ], - entries=entries + self_url=f"/opds/library/{library.id}", + links=[], + entries=entries, ) return feed + def create_collection_navigation_feed( library: m.Library, collection_type: str, items: Sequence[m.BookList | m.Tag | m.Author | m.Publisher | m.BookSeries], links: list[Link] = list(), # Title is usually derived from the model's name or title - get_title: Callable[[Any], str] = lambda x: getattr(x, 'title', getattr(x, 'name', str(x))) + get_title: Callable[[Any], str] = lambda x: getattr( + x, "title", getattr(x, "name", str(x)) + ), ) -> str: - + entries = [ Entry( id=f"/opds/library/{library.id}/{collection_type}/{item.id}", @@ -222,53 +223,43 @@ def create_collection_navigation_feed( href=f"/opds/acquisition?{collection_type}={item.id}&pageSize=50&paginated=1&feed_title={quote_plus(get_title(item))}&feed_id=/opds/library/{library.id}/{collection_type}/{item.id}&search=True", title=get_title(item), ) - ] - ) for item in items + ], + ) + for item in items ] return create_navigation_feed( id=f"/opds/library/{library.id}/{collection_type}", title=collection_type.title(), - self_url=f'/opds/library/{library.id}/{collection_type}', + self_url=f"/opds/library/{library.id}/{collection_type}", entries=entries, - links=links + links=links, ) + def create_next_paginated_link( - request: Request, - total: int, - current_count: int, - offset: int, - feed_title: str - ) -> Link | None: - if total <= current_count + offset: - return None - - params = dict(request.query_params) - params['currentPage'] = params.get('currentPage', 1) + 1 - - next_url = f"{request.url.path}?{urlencode(list(params.items()), doseq=True)}" - - return Link( - rel="next", - href=next_url, - title=feed_title, - type=LinkTypes.NAVIGATION - ) + request: Request, total: int, current_count: int, offset: int, feed_title: str +) -> Link | None: + if total <= current_count + offset: + return None + + params = dict(request.query_params) + params["currentPage"] = params.get("currentPage", 1) + 1 + + next_url = f"{request.url.path}?{urlencode(list(params.items()), doseq=True)}" + + return Link(rel="next", href=next_url, title=feed_title, type=LinkTypes.NAVIGATION) + def create_search_link( - request: Request, - exclude_params: set[str] | None = None + request: Request, exclude_params: set[str] | None = None ) -> Link: """Create search link with current filters applied""" if exclude_params is None: - exclude_params = {'currentPage', 'feed_title', 'feed_id', 'search', 'paginated'} - - params = { - k: v for k, v in request.query_params.items() - if k not in exclude_params - } - + exclude_params = {"currentPage", "feed_title", "feed_id", "search", "paginated"} + + params = {k: v for k, v in request.query_params.items() if k not in exclude_params} + return Link( rel="search", href=f"/opds/opensearch?{urlencode(list(params.items()), doseq=True)}", @@ -277,7 +268,6 @@ def create_search_link( ) - def create_pagination_links( request: Request, total: int, @@ -287,38 +277,35 @@ def create_pagination_links( link_type: str = LinkTypes.ACQUISITION, ) -> PaginationResult: """Create next/prev pagination links using limit/offset""" - + next_link = None prev_link = None - + # Create next link if there are more items if offset + limit < total: params = dict(request.query_params) # Calculate next page number current_page = (offset // limit) + 1 - params['currentPage'] = current_page + 1 - + params["currentPage"] = current_page + 1 + next_url = f"{request.url.path}?{urlencode(params, doseq=True)}" next_link = Link( - rel="next", - href=next_url, - title=f"{feed_title} - Next", - type=link_type + rel="next", href=next_url, title=f"{feed_title} - Next", type=link_type ) - + # Create previous link if not on first page if offset > 0: params = dict(request.query_params) # Calculate previous page number current_page = (offset // limit) + 1 - params['currentPage'] = max(1, current_page - 1) - + params["currentPage"] = max(1, current_page - 1) + prev_url = f"{request.url.path}?{urlencode(params, doseq=True)}" prev_link = Link( rel="previous", href=prev_url, title=f"{feed_title} - Previous", - type=link_type + type=link_type, ) - + return PaginationResult(next_link, prev_link, offset, total) diff --git a/backend/src/chitai/services/utils.py b/backend/src/chitai/services/utils.py index bd6b907..b5ef218 100644 --- a/backend/src/chitai/services/utils.py +++ b/backend/src/chitai/services/utils.py @@ -213,6 +213,7 @@ async def create_directory(dir_path: Path | str) -> None: await aios.makedirs(dir_path, exist_ok=True) + async def move_file(src_path: Path, dest_path: Path, create_dirs=True) -> None: """ Move a file from source to destination asynchronously. @@ -239,6 +240,7 @@ async def move_file(src_path: Path, dest_path: Path, create_dirs=True) -> None: # all configured separately, so they can easily be separate mounts. shutil.move(str(src_path), str(dest_path)) + async def copy_file(src_path: Path, dest_path: Path, create_dirs: bool = True) -> None: """ Copy a file, streaming it rather than reading it whole. @@ -451,7 +453,7 @@ def get_file_extension(file: Path | str | UploadFile) -> str | None: elif isinstance(file, UploadFile): return Path(file.filename).suffix.lower()[1:] - + raise ValueError("file object type is not supported") @@ -476,7 +478,7 @@ def get_filename(file: Path | str, ext: bool = True) -> str: filename = Path(file.name) else: raise ValueError("file object type is not supported") - + if ext: return str(filename) diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 27b587b..b1ca7b9 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -8,9 +8,14 @@ bun.lockb # Ignore artifacts: build coverage +.pytest_cache # Miscellaneous /static/ # Vendored third-party source, copied verbatim by scripts/vendor-foliate.sh /src/lib/vendor/ + +# Generated by openapi-typescript, which has its own formatting. Reformatting it here would +# make every regeneration a several-thousand-line diff. +/src/lib/schema/openapi/schema.d.ts diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 30448e4..baa669a 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -13,7 +13,9 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); export default defineConfig( includeIgnoreFile(gitignorePath), // Vendored third-party source. Tracked, so .gitignore does not cover it. - { ignores: ['src/lib/vendor/**'] }, + // static/pdfjs is the pdf.js viewer, vendored the same way as src/lib/vendor/foliate-js; + // linting it produced 1717 of the 1804 errors this config used to report. + { ignores: ['src/lib/vendor/**', 'static/pdfjs/**'] }, js.configs.recommended, ...ts.configs.recommended, ...svelte.configs.recommended, diff --git a/frontend/src/app.css b/frontend/src/app.css index 1ea6b25..dbc0c75 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -10,7 +10,8 @@ --radius: 0.625rem; /* Typography — system stacks, so nothing depends on a CDN or a webfont build. */ - --app-font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --app-font-sans: + system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; --app-font-serif: Georgia, 'Iowan Old Style', 'Times New Roman', serif; --app-font-mono: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace; diff --git a/frontend/src/lib/api/bookshelf.remote.ts b/frontend/src/lib/api/bookshelf.remote.ts index e46ca2f..1359ab4 100644 --- a/frontend/src/lib/api/bookshelf.remote.ts +++ b/frontend/src/lib/api/bookshelf.remote.ts @@ -1,5 +1,10 @@ import { command, getRequestEvent, query } from '$app/server'; -import { bookshelfCreate, bookshelfQuerySchema, modifyBooksInShelf, type Bookshelf } from '$lib/schema/bookshelf'; +import { + bookshelfCreate, + bookshelfQuerySchema, + modifyBooksInShelf, + type Bookshelf +} from '$lib/schema/bookshelf'; import { createQueryParams } from '$lib/utils'; import { error } from '@sveltejs/kit'; @@ -19,46 +24,51 @@ export const listBookshelves = query(bookshelfQuerySchema, async (data) => { return await response.json(); }); -export const addBooksToShelf = command(modifyBooksInShelf, async ({ shelf_id, ...data }): Promise => { - const { locals } = getRequestEvent(); +export const addBooksToShelf = command( + modifyBooksInShelf, + async ({ shelf_id, ...data }): Promise => { + const { locals } = getRequestEvent(); - const params = createQueryParams(data); + const params = createQueryParams(data); - const response = await locals.api.post(`/shelves/${shelf_id}/books?${params.toString()}`, {}); + const response = await locals.api.post(`/shelves/${shelf_id}/books?${params.toString()}`, {}); - if (!response.ok) { - const message = await response.text(); - error(response.status, message); + if (!response.ok) { + const message = await response.text(); + error(response.status, message); + } + + return await response.json(); } +); - return await response.json() -}); +export const removeBooksFromShelf = command( + modifyBooksInShelf, + async ({ shelf_id, ...data }): Promise => { + const { locals } = getRequestEvent(); -export const removeBooksFromShelf = command(modifyBooksInShelf, async ({ shelf_id, ...data }): Promise => { - const { locals } = getRequestEvent(); + const params = createQueryParams(data); - const params = createQueryParams(data); + const response = await locals.api.delete(`/shelves/${shelf_id}/books?${params.toString()}`); - const response = await locals.api.delete(`/shelves/${shelf_id}/books?${params.toString()}`); + if (!response.ok) { + const message = await response.text(); + error(response.status, message); + } - if (!response.ok) { - const message = await response.text(); - error(response.status, message); + return await response.json(); } - - return await response.json() -}); - +); export const createBookshelf = command(bookshelfCreate, async (data) => { const { locals } = getRequestEvent(); - const response = await locals.api.post(`/shelves`, data) + const response = await locals.api.post(`/shelves`, data); if (!response.ok) { const message = await response.text(); error(response.status, message); } - return await response.json() -}) + return await response.json(); +}); diff --git a/frontend/src/lib/api/device.remote.ts b/frontend/src/lib/api/device.remote.ts index 52db49d..2793899 100644 --- a/frontend/src/lib/api/device.remote.ts +++ b/frontend/src/lib/api/device.remote.ts @@ -4,40 +4,40 @@ import { error } from '@sveltejs/kit'; import z from 'zod'; export const listDevices = query(async (): Promise => { - const { locals } = getRequestEvent(); + const { locals } = getRequestEvent(); - const response = await locals.api.get(`/devices`); + const response = await locals.api.get(`/devices`); - if (!response.ok) error(500, 'An unkown error occurred'); + if (!response.ok) error(500, 'An unkown error occurred'); - const deviceResult = await response.json(); - return deviceResult.items + const deviceResult = await response.json(); + return deviceResult.items; }); export const createDevice = form(createDeviceSchema, async (data): Promise => { - 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 => { - 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 => { - 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'); }); diff --git a/frontend/src/lib/components/forms/shelf-create-dialog.svelte b/frontend/src/lib/components/forms/shelf-create-dialog.svelte index cb24fc9..5f8754e 100644 --- a/frontend/src/lib/components/forms/shelf-create-dialog.svelte +++ b/frontend/src/lib/components/forms/shelf-create-dialog.svelte @@ -1,35 +1,36 @@ - Create bookshelf -
- - -
+
+ + +
-
\ No newline at end of file + diff --git a/frontend/src/lib/components/layout/app-sidebar.svelte b/frontend/src/lib/components/layout/app-sidebar.svelte index 4c16f19..393186a 100644 --- a/frontend/src/lib/components/layout/app-sidebar.svelte +++ b/frontend/src/lib/components/layout/app-sidebar.svelte @@ -28,7 +28,9 @@ // directly in the markup keeps it static. const header = $derived({ title: 'chitai', - url: resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(libraryState.activeLibrary!.id) }) + url: resolve('/(root)/(library)/library/[libraryId]', { + libraryId: String(libraryState.activeLibrary!.id) + }) }); diff --git a/frontend/src/lib/components/layout/library-switcher.svelte b/frontend/src/lib/components/layout/library-switcher.svelte index db5e846..c02db2c 100644 --- a/frontend/src/lib/components/layout/library-switcher.svelte +++ b/frontend/src/lib/components/layout/library-switcher.svelte @@ -2,7 +2,7 @@ import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'; import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import { useSidebar } from '$lib/components/ui/sidebar/index.js'; - import { Badge } from "$lib/components/ui/badge/index.js"; + import { Badge } from '$lib/components/ui/badge/index.js'; import { getLibraryState, LibraryState } from '$lib/state/library.svelte'; import { LIBRARY_ICONS } from '$lib/components/ui/icon-picker/index.js'; import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down'; @@ -12,7 +12,9 @@ const libraryState = getLibraryState(); const sidebar = useSidebar(); - const activeIcon = $derived(LIBRARY_ICONS[libraryState.activeLibrary?.icon ?? 'library'] ?? LIBRARY_ICONS['library']); + const activeIcon = $derived( + LIBRARY_ICONS[libraryState.activeLibrary?.icon ?? 'library'] ?? LIBRARY_ICONS['library'] + ); @@ -35,7 +37,7 @@ {libraryState.activeLibrary!.name} - + {libraryState.activeLibrary!.total ?? 0} books @@ -51,15 +53,15 @@ > Libraries {#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} libraryState.setActive(library.id)} class="gap-2 p-2">
{library.name} - + {library.total ?? 0}
diff --git a/frontend/src/lib/components/ui/avatar/avatar-badge.svelte b/frontend/src/lib/components/ui/avatar/avatar-badge.svelte index 2df847a..f992690 100644 --- a/frontend/src/lib/components/ui/avatar/avatar-badge.svelte +++ b/frontend/src/lib/components/ui/avatar/avatar-badge.svelte @@ -1,6 +1,6 @@ @@ -19,7 +19,7 @@ data-slot="avatar" data-size={size} class={cn( - "size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:border-border after:mix-blend-darken dark:after:mix-blend-lighten", + 'group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten', className )} {...restProps} diff --git a/frontend/src/lib/components/ui/avatar/index.ts b/frontend/src/lib/components/ui/avatar/index.ts index 963a923..fcaad1a 100644 --- a/frontend/src/lib/components/ui/avatar/index.ts +++ b/frontend/src/lib/components/ui/avatar/index.ts @@ -1,9 +1,9 @@ -import Badge from "./avatar-badge.svelte"; -import Fallback from "./avatar-fallback.svelte"; -import GroupCount from "./avatar-group-count.svelte"; -import Group from "./avatar-group.svelte"; -import Image from "./avatar-image.svelte"; -import Root from "./avatar.svelte"; +import Badge from './avatar-badge.svelte'; +import Fallback from './avatar-fallback.svelte'; +import GroupCount from './avatar-group-count.svelte'; +import Group from './avatar-group.svelte'; +import Image from './avatar-image.svelte'; +import Root from './avatar.svelte'; export { Root, @@ -18,5 +18,5 @@ export { Fallback as AvatarFallback, Badge as AvatarBadge, Group as AvatarGroup, - GroupCount as AvatarGroupCount, + GroupCount as AvatarGroupCount }; diff --git a/frontend/src/lib/components/ui/button/button.svelte b/frontend/src/lib/components/ui/button/button.svelte index 864fbfd..754ab6e 100644 --- a/frontend/src/lib/components/ui/button/button.svelte +++ b/frontend/src/lib/components/ui/button/button.svelte @@ -15,8 +15,7 @@ secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', link: 'text-primary underline-offset-4 hover:underline', - accent: - 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90' + accent: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90' }, size: { default: 'h-9 px-4 py-2 has-[>svg]:px-3', diff --git a/frontend/src/lib/components/ui/icon-picker/icon-picker.svelte b/frontend/src/lib/components/ui/icon-picker/icon-picker.svelte index 9d236db..eaf9ca1 100644 --- a/frontend/src/lib/components/ui/icon-picker/icon-picker.svelte +++ b/frontend/src/lib/components/ui/icon-picker/icon-picker.svelte @@ -45,11 +45,7 @@ {/snippet} - +
{#each filteredIcons as [key, icon] (key)} @@ -58,7 +54,10 @@
{:else if bookCollection.books.length > 0} - + {#if bookCollection.view === 'grid'} {:else if bookCollection.view === 'list'} @@ -170,7 +174,7 @@ - + diff --git a/frontend/src/lib/components/view/book-grid.svelte b/frontend/src/lib/components/view/book-grid.svelte index 712be1b..e3220c2 100644 --- a/frontend/src/lib/components/view/book-grid.svelte +++ b/frontend/src/lib/components/view/book-grid.svelte @@ -73,8 +73,8 @@ bookOps.deleteDialogTitle = `Delete "${book.title}"?`; bookOps.deleteFn = async (deleteFiles: boolean) => { await bookOps.deleteBooks([book.id], deleteFiles); - libraryState.activeLibrary!.total!-- - bookshelfState.deletedBooks([book]) + libraryState.activeLibrary!.total!--; + bookshelfState.deletedBooks([book]); }; bookOps.deleteDialogOpen = true; }} diff --git a/frontend/src/lib/components/view/book-rows.svelte b/frontend/src/lib/components/view/book-rows.svelte index 2ee6864..ffe7d1c 100644 --- a/frontend/src/lib/components/view/book-rows.svelte +++ b/frontend/src/lib/components/view/book-rows.svelte @@ -121,7 +121,10 @@ ? 'cursor-pointer' : ''}" > - + diff --git a/frontend/src/lib/components/view/preset-chips.svelte b/frontend/src/lib/components/view/preset-chips.svelte index a0319d9..5574861 100644 --- a/frontend/src/lib/components/view/preset-chips.svelte +++ b/frontend/src/lib/components/view/preset-chips.svelte @@ -18,7 +18,7 @@ aria-pressed={active} onclick={() => (active ? bookCollection.clearView() : bookCollection.applyPreset(preset))} class="shrink-0 rounded-full border px-3 py-1 text-xs whitespace-nowrap transition-colors {active - ? 'border-primary bg-primary text-primary-foreground font-medium' + ? 'border-primary bg-primary font-medium text-primary-foreground' : 'border-border text-muted-foreground hover:border-muted-foreground hover:text-foreground'}" > {preset.label} diff --git a/frontend/src/lib/components/view/sort-button.svelte b/frontend/src/lib/components/view/sort-button.svelte index 5577d93..fc7a056 100644 --- a/frontend/src/lib/components/view/sort-button.svelte +++ b/frontend/src/lib/components/view/sort-button.svelte @@ -19,9 +19,7 @@ class="{buttonVariants({ variant: 'ghost', size: 'icon' })} relative" > {#if bookCollection.hasActiveSort} -
+
{/if} diff --git a/frontend/src/lib/schema/bookshelf.ts b/frontend/src/lib/schema/bookshelf.ts index 419a103..7e4fedc 100644 --- a/frontend/src/lib/schema/bookshelf.ts +++ b/frontend/src/lib/schema/bookshelf.ts @@ -19,7 +19,7 @@ export const bookshelfCreate = z.object({ title: z.string().min(1, 'Must have a title'), library_id: stringCoerce.optional(), book_ids: stringArrayCoerce.optional() -}) +}); export type BookshelfQuerySchema = typeof bookshelfQuerySchema; export type ModifyBooksInShelf = typeof modifyBooksInShelf; diff --git a/frontend/src/lib/schema/device.ts b/frontend/src/lib/schema/device.ts index 1a16cca..f5fb6bd 100644 --- a/frontend/src/lib/schema/device.ts +++ b/frontend/src/lib/schema/device.ts @@ -1,8 +1,8 @@ import { z } from 'zod'; import type { components } from './openapi/schema'; -export type Device = components['schemas']['KosyncDeviceRead'] +export type Device = components['schemas']['KosyncDeviceRead']; export const createDeviceSchema = z.object({ - name: z.string().min(1, 'Name cannot be empty') -}) \ No newline at end of file + name: z.string().min(1, 'Name cannot be empty') +}); diff --git a/frontend/src/lib/state/bookOperations.svelte.ts b/frontend/src/lib/state/bookOperations.svelte.ts index f81ef48..b042d74 100644 --- a/frontend/src/lib/state/bookOperations.svelte.ts +++ b/frontend/src/lib/state/bookOperations.svelte.ts @@ -1,16 +1,11 @@ import { invalidate } from '$app/navigation'; import { page } from '$app/state'; -import { - deleteBookFiles, - deleteBooks, - listBooks, - updateBookProgress -} from '$lib/api'; +import { deleteBookFiles, deleteBooks, listBooks, updateBookProgress } from '$lib/api'; import { type Book, type UpdateBookProgress, type BookQuery, - type PaginatedResponse, + type PaginatedResponse } from '$lib/schema'; import { getContext, setContext } from 'svelte'; import { toast } from 'svelte-sonner'; diff --git a/frontend/src/lib/state/bookSelection.svelte.ts b/frontend/src/lib/state/bookSelection.svelte.ts index 9e800d1..76abdef 100644 --- a/frontend/src/lib/state/bookSelection.svelte.ts +++ b/frontend/src/lib/state/bookSelection.svelte.ts @@ -7,12 +7,9 @@ export class BookSelectionState { readonly selectionModeActive = $derived(this.selectedBooks.size !== 0); toggleSelection(book: Book) { - const bookId = book.id.toString() - if (this.selectedBooks.has(bookId)) - this.selectedBooks.delete(bookId); - - else - this.selectedBooks.set(bookId, book); + const bookId = book.id.toString(); + if (this.selectedBooks.has(bookId)) this.selectedBooks.delete(bookId); + else this.selectedBooks.set(bookId, book); } isSelected(id: number | string): boolean { @@ -20,11 +17,11 @@ export class BookSelectionState { } getSelectedBooks(): Book[] { - return Array.from(this.selectedBooks.values()) + return Array.from(this.selectedBooks.values()); } getSelectedIds(): string[] { - return Array.from(this.selectedBooks.keys()) + return Array.from(this.selectedBooks.keys()); } numSelected() { @@ -33,9 +30,8 @@ export class BookSelectionState { selectAll(books: Book[]) { books.forEach((book) => { - const bookId = book.id.toString() - if (!this.selectedBooks.has(bookId)) - this.selectedBooks.set(bookId, book) + const bookId = book.id.toString(); + if (!this.selectedBooks.has(bookId)) this.selectedBooks.set(bookId, book); }); } diff --git a/frontend/src/lib/state/bookshelf.svelte.ts b/frontend/src/lib/state/bookshelf.svelte.ts index f9a402a..d051569 100644 --- a/frontend/src/lib/state/bookshelf.svelte.ts +++ b/frontend/src/lib/state/bookshelf.svelte.ts @@ -9,16 +9,16 @@ export class BookshelfState { readonly libraryBookshelves = new SvelteMap(); getBookshelves(libraryId: string | number): Bookshelf[] | undefined { - const id = libraryId.toString(); - - if (!this.libraryBookshelves.has(id)) { - this.fetchBookshelves(id).then(shelves => { - this.libraryBookshelves.set(id, shelves); - }); - } - - return this.libraryBookshelves.get(id); - } + const id = libraryId.toString(); + + if (!this.libraryBookshelves.has(id)) { + this.fetchBookshelves(id).then((shelves) => { + this.libraryBookshelves.set(id, shelves); + }); + } + + return this.libraryBookshelves.get(id); + } async fetchBookshelves(libraryId: string) { try { @@ -38,108 +38,109 @@ export class BookshelfState { title: name, library_id: libraryId, book_ids: booksToAdd - }) - + }); + if (bookshelf.library_id) { const currentShelves = this.libraryBookshelves.get(bookshelf.library_id.toString()) || []; - this.libraryBookshelves.set(bookshelf.library_id.toString(), [...currentShelves, bookshelf]); + this.libraryBookshelves.set(bookshelf.library_id.toString(), [ + ...currentShelves, + bookshelf + ]); } - invalidate('app:books') + invalidate('app:books'); - if (booksToAdd?.length) - toast.success(`Added ${booksToAdd.length} books to '${name}'`) - else - toast.success(`Created shelf '${name}'`) - - return bookshelf + if (booksToAdd?.length) toast.success(`Added ${booksToAdd.length} books to '${name}'`); + else toast.success(`Created shelf '${name}'`); + return bookshelf; } catch (error) { - toast.error(`Failed to create bookshelf '${name}'`) - console.error(`Failed to create bookshelf: `, error) + toast.error(`Failed to create bookshelf '${name}'`); + console.error(`Failed to create bookshelf: `, error); } } async addBooksToShelf(shelfId: number | string, bookIds: number[]) { - try { - const shelf = await addBooksToShelf({ - shelf_id: shelfId, - book_ids: bookIds - }); - - this.updateShelf(shelf) - - if (bookIds.length === 1) toast.success(`Added book to shelf!`); - else toast.success(`Added ${bookIds.length} books to shelf!`); - } catch (error) { - console.error('Failed to add book(s) to shelf: ', error); - toast.error('Failed to add book(s) to shelf.'); - } - } - - async removeBooksFromShelf(shelfId: number | string, bookIds: number[]) { - try { - const shelf = await removeBooksFromShelf({ - shelf_id: shelfId, - book_ids: bookIds - }); - - this.updateShelf(shelf) - - if (bookIds.length === 1) toast.success(`Removed book from shelf.`); - else toast.success(`Removed ${bookIds.length} books from shelf!`); - } catch (error) { - console.error('Failed to remove book(s) from shelf: ', error); - toast.error('Failed to remove book(s) from shelf.'); - } - } - - async updateShelf(shelf: Bookshelf) { - if (!shelf.library_id) return; - const bookshelves = this.libraryBookshelves.get(shelf.library_id.toString()) - if (!bookshelves) return; - - const index = bookshelves.findIndex(bookshelf => bookshelf.id === shelf.id) - if (index !== -1) { - this.libraryBookshelves.set( - shelf.library_id.toString(), - [...bookshelves.slice(0, index), shelf, ...bookshelves.slice(index + 1)] - ); - } - } - - deletedBooks(books: Book[]) { - // Assume all books are in the same library - const libraryId = books[0].library_id.toString() - const bookshelves = this.libraryBookshelves.get(libraryId); - if (!bookshelves) return; - - // Create a Set of shelf IDs that need updating for efficient lookup - const shelfIdsToUpdate = new Set(); - books.forEach(book => { - book.lists.forEach(shelf => { - shelfIdsToUpdate.add(shelf.id); - }); + try { + const shelf = await addBooksToShelf({ + shelf_id: shelfId, + book_ids: bookIds }); - - const updatedBookshelves = bookshelves.map(shelf => { - if (shelfIdsToUpdate.has(shelf.id)) { - // Count how many times this shelf appears across all deleted books - let decrementBy = 0; - books.forEach(book => { - if (book.lists.some(s => s.id === shelf.id)) { - decrementBy++; - } - }); - return { ...shelf, total: shelf.total - decrementBy }; - } - return shelf; - }); - - this.libraryBookshelves.set(libraryId, updatedBookshelves); + + this.updateShelf(shelf); + + if (bookIds.length === 1) toast.success(`Added book to shelf!`); + else toast.success(`Added ${bookIds.length} books to shelf!`); + } catch (error) { + console.error('Failed to add book(s) to shelf: ', error); + toast.error('Failed to add book(s) to shelf.'); } } + async removeBooksFromShelf(shelfId: number | string, bookIds: number[]) { + try { + const shelf = await removeBooksFromShelf({ + shelf_id: shelfId, + book_ids: bookIds + }); + + this.updateShelf(shelf); + + if (bookIds.length === 1) toast.success(`Removed book from shelf.`); + else toast.success(`Removed ${bookIds.length} books from shelf!`); + } catch (error) { + console.error('Failed to remove book(s) from shelf: ', error); + toast.error('Failed to remove book(s) from shelf.'); + } + } + + async updateShelf(shelf: Bookshelf) { + if (!shelf.library_id) return; + const bookshelves = this.libraryBookshelves.get(shelf.library_id.toString()); + if (!bookshelves) return; + + const index = bookshelves.findIndex((bookshelf) => bookshelf.id === shelf.id); + if (index !== -1) { + this.libraryBookshelves.set(shelf.library_id.toString(), [ + ...bookshelves.slice(0, index), + shelf, + ...bookshelves.slice(index + 1) + ]); + } + } + + deletedBooks(books: Book[]) { + // Assume all books are in the same library + const libraryId = books[0].library_id.toString(); + const bookshelves = this.libraryBookshelves.get(libraryId); + if (!bookshelves) return; + + // Create a Set of shelf IDs that need updating for efficient lookup + const shelfIdsToUpdate = new Set(); + books.forEach((book) => { + book.lists.forEach((shelf) => { + shelfIdsToUpdate.add(shelf.id); + }); + }); + + const updatedBookshelves = bookshelves.map((shelf) => { + if (shelfIdsToUpdate.has(shelf.id)) { + // Count how many times this shelf appears across all deleted books + let decrementBy = 0; + books.forEach((book) => { + if (book.lists.some((s) => s.id === shelf.id)) { + decrementBy++; + } + }); + return { ...shelf, total: shelf.total - decrementBy }; + } + return shelf; + }); + + this.libraryBookshelves.set(libraryId, updatedBookshelves); + } +} + const BOOKSHELF_KEY = Symbol('BOOKSHELF'); export function setBookshelfState() { diff --git a/frontend/src/lib/state/library.svelte.ts b/frontend/src/lib/state/library.svelte.ts index ac315ab..66d6009 100644 --- a/frontend/src/lib/state/library.svelte.ts +++ b/frontend/src/lib/state/library.svelte.ts @@ -32,9 +32,12 @@ export class LibraryState { this.activeLibrary = this.libraries.find((lib) => lib.id === libraryId) || this.libraries[0]; if (browser) { localStorage.setItem('previousLibraryId', this.activeLibrary.id.toString()); - await goto(resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(libraryId) }), { - invalidate: ['app:libraries'] - }); + await goto( + resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(libraryId) }), + { + invalidate: ['app:libraries'] + } + ); } } diff --git a/frontend/src/lib/theme/presets.ts b/frontend/src/lib/theme/presets.ts index c2a4c8b..6caae7f 100644 --- a/frontend/src/lib/theme/presets.ts +++ b/frontend/src/lib/theme/presets.ts @@ -44,7 +44,10 @@ export const FONT_STACKS: { label: string; value: string }[] = [ { label: 'Palatino', value: "'Palatino Linotype', Palatino, 'Book Antiqua', serif" }, { label: 'Helvetica', value: "'Helvetica Neue', Helvetica, Arial, sans-serif" }, { label: 'Verdana', value: 'Verdana, Geneva, sans-serif' }, - { label: 'Monospace', value: "ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace" } + { + label: 'Monospace', + value: "ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace" + } ]; export type Palette = Record; @@ -299,7 +302,10 @@ export function parseThemeCookie(raw: string | undefined | null): ThemeConfig { try { const parsed = JSON.parse(decodeURIComponent(raw)); if (!parsed || typeof parsed !== 'object') return { preset: DEFAULT_PRESET_ID }; - return { ...parsed, preset: typeof parsed.preset === 'string' ? parsed.preset : DEFAULT_PRESET_ID }; + return { + ...parsed, + preset: typeof parsed.preset === 'string' ? parsed.preset : DEFAULT_PRESET_ID + }; } catch { return { preset: DEFAULT_PRESET_ID }; } diff --git a/frontend/src/lib/theme/theme.svelte.ts b/frontend/src/lib/theme/theme.svelte.ts index 3495df5..84a4429 100644 --- a/frontend/src/lib/theme/theme.svelte.ts +++ b/frontend/src/lib/theme/theme.svelte.ts @@ -35,9 +35,9 @@ export class ThemeState { readonly isCustomised = $derived( Boolean( this.config.radius || - this.config.fonts || - Object.keys(this.config.light ?? {}).length || - Object.keys(this.config.dark ?? {}).length + this.config.fonts || + Object.keys(this.config.light ?? {}).length || + Object.keys(this.config.dark ?? {}).length ) ); diff --git a/frontend/src/routes/(root)/(library)/library/[libraryId]/+page.svelte b/frontend/src/routes/(root)/(library)/library/[libraryId]/+page.svelte index a5ddd4b..d660c16 100644 --- a/frontend/src/routes/(root)/(library)/library/[libraryId]/+page.svelte +++ b/frontend/src/routes/(root)/(library)/library/[libraryId]/+page.svelte @@ -1,15 +1,14 @@ @@ -22,7 +21,7 @@ {#if data.presets.every((preset) => data.shelves[preset.id].length === 0)} -
+
diff --git a/frontend/src/routes/(root)/+layout.svelte b/frontend/src/routes/(root)/+layout.svelte index 6f9d39a..901efb6 100644 --- a/frontend/src/routes/(root)/+layout.svelte +++ b/frontend/src/routes/(root)/+layout.svelte @@ -44,7 +44,6 @@ }); -
diff --git a/frontend/src/routes/(root)/settings/appearance/+page.svelte b/frontend/src/routes/(root)/settings/appearance/+page.svelte index a479abf..f15d323 100644 --- a/frontend/src/routes/(root)/settings/appearance/+page.svelte +++ b/frontend/src/routes/(root)/settings/appearance/+page.svelte @@ -71,7 +71,8 @@
- +
{#each COLOR_TOKENS as token (token.key)}
@@ -94,7 +95,8 @@
- +
{#each RADII as option (option.value)}