chore: format the tree and clear ruff's findings

Applies ruff format, ruff check --fix and prettier, so CI can gate on all three.
The surviving unused imports were all re-exports in __init__.py, now covered by a
per-file ignore; the pdf.js viewer and the generated openapi types join the
vendored code that eslint and prettier already skip.
This commit is contained in:
2026-08-17 15:17:14 -04:00
parent b70ed5cb51
commit 45b03764d2
85 changed files with 847 additions and 709 deletions
+5
View File
@@ -38,6 +38,11 @@ dev = [
"pytest-databases[postgres]>=0.15.0", "pytest-databases[postgres]>=0.15.0",
] ]
[tool.ruff.lint.per-file-ignores]
# The package __init__.py files exist to re-export their modules' public names, so every
# import in them is "unused" as far as F401 is concerned.
"__init__.py" = ["F401"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
testpaths = ["tests"] testpaths = ["tests"]
+5 -1
View File
@@ -72,6 +72,7 @@ oauth2_auth = OAuth2PasswordBearerAuth[User](
watcher_task: asyncio.Task watcher_task: asyncio.Task
@asynccontextmanager @asynccontextmanager
async def setup_db_connection(app: Litestar) -> AsyncGenerator[None, None]: async def setup_db_connection(app: Litestar) -> AsyncGenerator[None, None]:
# Setup databse # Setup databse
@@ -107,7 +108,9 @@ async def setup_directory_watcher(app: Litestar) -> AsyncGenerator[None, None]:
book_service = BookService(session=db_session) book_service = BookService(session=db_session)
library_service = LibraryService(session=db_session) library_service = LibraryService(session=db_session)
file_watcher = ConsumeDirectoryWatcher(settings.consume_path, library_service, book_service) file_watcher = ConsumeDirectoryWatcher(
settings.consume_path, library_service, book_service
)
watcher_task = asyncio.create_task(file_watcher.init_watcher()) watcher_task = asyncio.create_task(file_watcher.init_watcher())
try: try:
@@ -115,6 +118,7 @@ async def setup_directory_watcher(app: Litestar) -> AsyncGenerator[None, None]:
finally: finally:
watcher_task.cancel() watcher_task.cancel()
def create_app() -> Litestar: def create_app() -> Litestar:
return Litestar( return Litestar(
route_handlers=[ route_handlers=[
+1 -1
View File
@@ -1,7 +1,7 @@
# src/chitai/controllers/access.py # src/chitai/controllers/access.py
# Standard library # Standard library
from typing import Annotated, Any from typing import Annotated
import logging import logging
# Third-party libraries # Third-party libraries
+1 -2
View File
@@ -4,9 +4,8 @@
from typing import Annotated from typing import Annotated
# Third-party libraries # Third-party libraries
from litestar import Controller, post, get, patch, delete from litestar import Controller, get
from litestar.params import Dependency from litestar.params import Dependency
from litestar.exceptions import HTTPException
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
from advanced_alchemy.service.pagination import OffsetPagination from advanced_alchemy.service.pagination import OffsetPagination
from advanced_alchemy.service import FilterTypeT from advanced_alchemy.service import FilterTypeT
+23 -19
View File
@@ -8,47 +8,51 @@ from litestar import Controller, post, get, delete
from litestar.di import Provide from litestar.di import Provide
from chitai.services import dependencies as deps from chitai.services import dependencies as deps
class DeviceController(Controller): class DeviceController(Controller):
"""Controller for managing KOReader devices.""" """Controller for managing KOReader devices."""
dependencies = { dependencies = {"device_service": Provide(deps.provide_kosync_device_service)}
"device_service": Provide(deps.provide_kosync_device_service)
}
path = "/devices" path = "/devices"
@get() @get()
async def get_devices(self, device_service: KosyncDeviceService, current_user: User) -> OffsetPagination[KosyncDeviceRead]: async def get_devices(
self, device_service: KosyncDeviceService, current_user: User
) -> OffsetPagination[KosyncDeviceRead]:
"""Return a list of all the user's devices.""" """Return a list of all the user's devices."""
devices = await device_service.list( devices = await device_service.list(KosyncDevice.user_id == current_user.id)
KosyncDevice.user_id == current_user.id
)
return device_service.to_schema(devices, schema_type=KosyncDeviceRead) return device_service.to_schema(devices, schema_type=KosyncDeviceRead)
@post() @post()
async def create_device(self, data: KosyncDeviceCreate, device_service: KosyncDeviceService, current_user: User) -> KosyncDeviceRead: async def create_device(
device = await device_service.create({ self,
'name': data.name, data: KosyncDeviceCreate,
'user_id': current_user.id device_service: KosyncDeviceService,
}) current_user: User,
) -> KosyncDeviceRead:
device = await device_service.create(
{"name": data.name, "user_id": current_user.id}
)
return device_service.to_schema(device, schema_type=KosyncDeviceRead) return device_service.to_schema(device, schema_type=KosyncDeviceRead)
@delete("/{device_id:int}") @delete("/{device_id:int}")
async def delete_device(self, device_id: int, device_service: KosyncDeviceService, current_user: User) -> None: async def delete_device(
self, device_id: int, device_service: KosyncDeviceService, current_user: User
) -> None:
# Ensure the device exists and is owned by the user # Ensure the device exists and is owned by the user
device = await device_service.get_one( device = await device_service.get_one(
KosyncDevice.id == device_id, KosyncDevice.id == device_id, KosyncDevice.user_id == current_user.id
KosyncDevice.user_id == current_user.id
) )
await device_service.delete(device.id) await device_service.delete(device.id)
@get("/{device_id:int}/regenerate") @get("/{device_id:int}/regenerate")
async def regenerate_device_api_key(self, device_id: int, device_service: KosyncDeviceService, current_user: User) -> KosyncDeviceRead: async def regenerate_device_api_key(
self, device_id: int, device_service: KosyncDeviceService, current_user: User
) -> KosyncDeviceRead:
# Ensure the device exists and is owned by the user # Ensure the device exists and is owned by the user
device = await device_service.get_one( device = await device_service.get_one(
KosyncDevice.id == device_id, KosyncDevice.id == device_id, KosyncDevice.user_id == current_user.id
KosyncDevice.user_id == current_user.id
) )
updated_device = await device_service.regenerate_api_key(device.id) updated_device = await device_service.regenerate_api_key(device.id)
return device_service.to_schema(updated_device, schema_type=KosyncDeviceRead) return device_service.to_schema(updated_device, schema_type=KosyncDeviceRead)
@@ -58,10 +58,14 @@ class KosyncController(Controller):
user: m.User, user: m.User,
) -> KosyncProgressRead: ) -> KosyncProgressRead:
"""Return the Kosync progress record associated with the given document.""" """Return the Kosync progress record associated with the given document."""
progress = await kosync_progress_service.get_by_document_hash(user.id, document_id) progress = await kosync_progress_service.get_by_document_hash(
user.id, document_id
)
if not progress: if not progress:
raise HTTPException(status_code=404, detail="No progress found for document") raise HTTPException(
status_code=404, detail="No progress found for document"
)
return KosyncProgressRead( return KosyncProgressRead(
document=progress.document, document=progress.document,
@@ -85,6 +89,3 @@ class KosyncController(Controller):
detail="User accounts must be created via the main application", detail="User accounts must be created via the main application",
status_code=HTTP_403_FORBIDDEN, status_code=HTTP_403_FORBIDDEN,
) )
+4 -2
View File
@@ -10,7 +10,7 @@ from typing import Annotated
# Third-party libraries # Third-party libraries
import aiofiles import aiofiles
from aiofiles import os as aios from aiofiles import os as aios
from litestar import Controller, post, get, patch, delete from litestar import Controller, post, get, delete
from litestar.enums import RequestEncodingType from litestar.enums import RequestEncodingType
from litestar.params import Body, Dependency from litestar.params import Body, Dependency
from litestar.exceptions import HTTPException from litestar.exceptions import HTTPException
@@ -89,7 +89,9 @@ class LibraryController(Controller):
Injected Dependencies: Injected Dependencies:
library_service: The service used to query and return library data. library_service: The service used to query and return library data.
""" """
results, total = await library_service.list_and_count(*filters, load=[m.Library.books]) results, total = await library_service.list_and_count(
*filters, load=[m.Library.books]
)
return library_service.to_schema( return library_service.to_schema(
results, total, filters, schema_type=LibraryRead results, total, filters, schema_type=LibraryRead
) )
+65 -51
View File
@@ -1,4 +1,3 @@
from chitai.services import dependencies as deps from chitai.services import dependencies as deps
from chitai.database import models as m from chitai.database import models as m
from chitai.services.author import AuthorService from chitai.services.author import AuthorService
@@ -6,7 +5,15 @@ from chitai.services.filters.author import AuthorLibraryFilter
from chitai.services.filters.publisher import PublisherLibraryFilter from chitai.services.filters.publisher import PublisherLibraryFilter
from chitai.services.filters.tags import TagLibraryFilter from chitai.services.filters.tags import TagLibraryFilter
from chitai.services.opds.models import Entry, Link, LinkTypes, LinkRelations from chitai.services.opds.models import Entry, Link, LinkTypes, LinkRelations
from chitai.services.opds.opds import create_acquisition_feed, create_navigation_feed, create_library_navigation_feed, create_collection_navigation_feed, create_pagination_links, create_search_link, get_opensearch_document from chitai.services.opds.opds import (
create_acquisition_feed,
create_navigation_feed,
create_library_navigation_feed,
create_collection_navigation_feed,
create_pagination_links,
create_search_link,
get_opensearch_document,
)
from chitai.services import BookService, ShelfService, LibraryService from chitai.services import BookService, ShelfService, LibraryService
from chitai.services.publisher import PublisherService from chitai.services.publisher import PublisherService
from chitai.services.tag import TagService from chitai.services.tag import TagService
@@ -22,7 +29,6 @@ from litestar.params import Dependency
from advanced_alchemy.service import FilterTypeT from advanced_alchemy.service import FilterTypeT
class OpdsController(Controller): class OpdsController(Controller):
"""Controller for managing OPDS endpoints""" """Controller for managing OPDS endpoints"""
@@ -76,10 +82,11 @@ class OpdsController(Controller):
title=lib.name, title=lib.name,
href=f"/opds/library/{lib.id}", href=f"/opds/library/{lib.id}",
rel=LinkRelations.SUBSECTION, rel=LinkRelations.SUBSECTION,
type=LinkTypes.NAVIGATION type=LinkTypes.NAVIGATION,
) )
] ],
) for lib in libraries )
for lib in libraries
] ]
feed = create_navigation_feed( feed = create_navigation_feed(
@@ -94,13 +101,10 @@ class OpdsController(Controller):
title="Search books", title="Search books",
) )
], ],
entries=entries entries=entries,
) )
return Response( return Response(feed, media_type="application/xml")
feed,
media_type="application/xml"
)
@get("/acquisition") @get("/acquisition")
async def get_acquisition_feed( async def get_acquisition_feed(
@@ -109,8 +113,10 @@ class OpdsController(Controller):
feed_id: str, feed_id: str,
feed_title: str, feed_title: str,
books_service: BookService, books_service: BookService,
book_filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [], book_filters: Annotated[
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [] list[FilterTypeT], Dependency(skip_validation=True)
] = [],
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [],
) -> Response: ) -> Response:
all_filters = [*filters, *book_filters] all_filters = [*filters, *book_filters]
@@ -121,20 +127,22 @@ class OpdsController(Controller):
links = [] links = []
# Create pagination links if it is a paginated feed # Create pagination links if it is a paginated feed
if request.query_params.get('paginated'): if request.query_params.get("paginated"):
pagination = create_pagination_links( pagination = create_pagination_links(
request=request, request=request,
total=total, total=total,
limit=limit, limit=limit,
offset=offset, offset=offset,
feed_title=feed_title, feed_title=feed_title,
link_type=LinkTypes.ACQUISITION link_type=LinkTypes.ACQUISITION,
) )
links.extend([link for link in [pagination.next_link, pagination.prev_link] if link]) links.extend(
[link for link in [pagination.next_link, pagination.prev_link] if link]
)
# Add search link if this is a searchable feed # Add search link if this is a searchable feed
if request.query_params.get('search'): if request.query_params.get("search"):
links.append(create_search_link(request)) links.append(create_search_link(request))
# Create self URL # Create self URL
@@ -150,15 +158,14 @@ class OpdsController(Controller):
return Response(feed, media_type="application/xml") return Response(feed, media_type="application/xml")
@get("/opensearch") @get("/opensearch")
async def opensearch(self, user: m.User, request: Request) -> Response: async def opensearch(self, user: m.User, request: Request) -> Response:
return Response( return Response(
get_opensearch_document( get_opensearch_document(
base_url=f'/opds/search?{urlencode(list(request.query_params.items()), doseq=True)}&' base_url=f"/opds/search?{urlencode(list(request.query_params.items()), doseq=True)}&"
), ),
media_type="application/xml" media_type="application/xml",
) )
@get("/library/{library_id:int}") @get("/library/{library_id:int}")
@@ -168,7 +175,6 @@ class OpdsController(Controller):
return Response(feed, media_type="application/xml") return Response(feed, media_type="application/xml")
@get("/library/{library_id:int}/{collection_type:str}") @get("/library/{library_id:int}/{collection_type:str}")
async def get_library_collection_feed( async def get_library_collection_feed(
self, self,
@@ -180,34 +186,46 @@ class OpdsController(Controller):
tag_service: TagService, tag_service: TagService,
publisher_service: PublisherService, publisher_service: PublisherService,
request: Request, request: Request,
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [] filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [],
) -> Response: ) -> Response:
service_map = { service_map = {
'shelves': (shelf_service, lambda: shelf_service.list_and_count( "shelves": (
shelf_service,
lambda: shelf_service.list_and_count(
*filters, *filters,
CollectionFilter("library_id", values=[library.id]), CollectionFilter("library_id", values=[library.id]),
OrderBy("name", "asc"), OrderBy("name", "asc"),
m.BookList.user_id == user.id m.BookList.user_id == user.id,
)), ),
'tags': (tag_service, lambda: tag_service.list_and_count( ),
"tags": (
tag_service,
lambda: tag_service.list_and_count(
*filters, *filters,
TagLibraryFilter(libraries=[library.id]), TagLibraryFilter(libraries=[library.id]),
OrderBy("name", "asc"), OrderBy("name", "asc"),
uniquify=True, uniquify=True,
)), ),
'authors': (author_service, lambda: author_service.list_and_count( ),
"authors": (
author_service,
lambda: author_service.list_and_count(
*filters, *filters,
AuthorLibraryFilter(libraries=[library.id]), AuthorLibraryFilter(libraries=[library.id]),
OrderBy("name", "asc"), OrderBy("name", "asc"),
uniquify=True uniquify=True,
)), ),
'publishers': (publisher_service, lambda: publisher_service.list_and_count( ),
"publishers": (
publisher_service,
lambda: publisher_service.list_and_count(
*filters, *filters,
PublisherLibraryFilter(libraries=[library.id]), PublisherLibraryFilter(libraries=[library.id]),
OrderBy("name", "asc"), OrderBy("name", "asc"),
uniquify=True uniquify=True,
)) ),
),
} }
if collection_type not in service_map: if collection_type not in service_map:
@@ -220,38 +238,37 @@ class OpdsController(Controller):
# Create pagination links if it is a paginated feed # Create pagination links if it is a paginated feed
limit, offset = extract_limit_offset(filters) limit, offset = extract_limit_offset(filters)
if request.query_params.get('paginated'): if request.query_params.get("paginated"):
pagination = create_pagination_links( pagination = create_pagination_links(
request=request, request=request,
total=total, total=total,
limit=limit, limit=limit,
offset=offset, offset=offset,
feed_title=collection_type, feed_title=collection_type,
link_type=LinkTypes.ACQUISITION link_type=LinkTypes.ACQUISITION,
) )
links.extend([link for link in [pagination.next_link, pagination.prev_link] if link]) links.extend(
[link for link in [pagination.next_link, pagination.prev_link] if link]
)
feed = create_collection_navigation_feed(library, collection_type, items, links) feed = create_collection_navigation_feed(library, collection_type, items, links)
return Response(feed, media_type="application/xml") return Response(feed, media_type="application/xml")
@get("/search") @get("/search")
async def search_books( async def search_books(
self, books_service: BookService, self,
books_service: BookService,
request: Request, request: Request,
book_filters: Annotated[ book_filters: Annotated[
list[FilterTypeT], Dependency(skip_validation=True) list[FilterTypeT], Dependency(skip_validation=True)
] = [], ] = [],
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [] filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [],
) -> Response: ) -> Response:
filters = [*filters, *book_filters] filters = [*filters, *book_filters]
books, total = await books_service.list_and_count( books, total = await books_service.list_and_count(*filters)
*filters
)
limit, offset = extract_limit_offset(filters) limit, offset = extract_limit_offset(filters)
@@ -262,17 +279,17 @@ class OpdsController(Controller):
limit=limit, limit=limit,
offset=offset, offset=offset,
feed_title="Search Results", feed_title="Search Results",
link_type=LinkTypes.ACQUISITION link_type=LinkTypes.ACQUISITION,
) )
links = [link for link in [pagination.next_link, pagination.prev_link] if link] links = [link for link in [pagination.next_link, pagination.prev_link] if link]
catalog_xml = create_acquisition_feed( catalog_xml = create_acquisition_feed(
id=f"/opds/search?q=q", id="/opds/search?q=q",
title="Search results", title="Search results",
url=f"/opds/search?q=q", url="/opds/search?q=q",
books=books, books=books,
links=links links=links,
) )
return Response(catalog_xml, media_type="application/xml") return Response(catalog_xml, media_type="application/xml")
@@ -287,10 +304,7 @@ class OpdsController(Controller):
def extract_limit_offset(filters: list[FilterTypeT]) -> tuple[int, int]: def extract_limit_offset(filters: list[FilterTypeT]) -> tuple[int, int]:
"""Extract page size and offset from filters""" """Extract page size and offset from filters"""
limit_offset_filter = next( limit_offset_filter = next((f for f in filters if isinstance(f, LimitOffset)), None)
(f for f in filters if isinstance(f, LimitOffset)),
None
)
if limit_offset_filter: if limit_offset_filter:
return limit_offset_filter.limit, limit_offset_filter.offset return limit_offset_filter.limit, limit_offset_filter.offset
+1 -1
View File
@@ -4,7 +4,7 @@
from typing import Annotated from typing import Annotated
# Third-party libraries # Third-party libraries
from litestar import Controller, post, get, patch, delete from litestar import Controller, get
from litestar.params import Dependency from litestar.params import Dependency
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
from advanced_alchemy.service.pagination import OffsetPagination from advanced_alchemy.service.pagination import OffsetPagination
+3 -1
View File
@@ -70,7 +70,9 @@ class BookshelfController(Controller):
filters.append(CollectionFilter("library_id", values=libraries)) filters.append(CollectionFilter("library_id", values=libraries))
filters.append(m.BookList.user_id == current_user.id) filters.append(m.BookList.user_id == current_user.id)
results, total = await shelf_service.list_and_count(*filters, load=[selectinload(m.BookList.book_links)]) results, total = await shelf_service.list_and_count(
*filters, load=[selectinload(m.BookList.book_links)]
)
return shelf_service.to_schema(results, total, filters, schema_type=ShelfRead) return shelf_service.to_schema(results, total, filters, schema_type=ShelfRead)
@post() @post()
+1 -1
View File
@@ -4,7 +4,7 @@
from typing import Annotated from typing import Annotated
# Third-party libraries # Third-party libraries
from litestar import Controller, post, get, patch, delete from litestar import Controller, get
from litestar.params import Dependency from litestar.params import Dependency
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
from advanced_alchemy.service.pagination import OffsetPagination from advanced_alchemy.service.pagination import OffsetPagination
@@ -1,7 +1,6 @@
from typing import Optional from typing import Optional
from sqlalchemy import ForeignKey from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import relationship
from advanced_alchemy.base import BigIntAuditBase from advanced_alchemy.base import BigIntAuditBase
@@ -1,12 +1,10 @@
from collections.abc import Hashable from collections.abc import Hashable
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import ColumnElement, ForeignKey from sqlalchemy import ColumnElement
from advanced_alchemy.base import BigIntAuditBase from advanced_alchemy.base import BigIntAuditBase
from advanced_alchemy.mixins import UniqueMixin from advanced_alchemy.mixins import UniqueMixin
from .book import Book
class BookSeries(BigIntAuditBase, UniqueMixin): class BookSeries(BigIntAuditBase, UniqueMixin):
__tablename__ = "book_series" __tablename__ = "book_series"
@@ -1,9 +1,10 @@
from sqlalchemy import ColumnElement, ForeignKey from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column from sqlalchemy.orm import mapped_column
from advanced_alchemy.base import BigIntAuditBase from advanced_alchemy.base import BigIntAuditBase
class KosyncDevice(BigIntAuditBase): class KosyncDevice(BigIntAuditBase):
__tablename__ = "devices" __tablename__ = "devices"
@@ -1,6 +1,4 @@
from litestar import Request, Response, MediaType from litestar import Request, Response, MediaType
from litestar.exceptions import HTTPException
from litestar.status_codes import HTTP_404_NOT_FOUND
from advanced_alchemy.exceptions import NotFoundError from advanced_alchemy.exceptions import NotFoundError
+10 -5
View File
@@ -3,7 +3,7 @@ from chitai.services.user import UserService
from litestar.middleware import ( from litestar.middleware import (
AbstractAuthenticationMiddleware, AbstractAuthenticationMiddleware,
AuthenticationResult, AuthenticationResult,
DefineMiddleware DefineMiddleware,
) )
from litestar.connection import ASGIConnection from litestar.connection import ASGIConnection
from litestar.exceptions import NotAuthorizedException, PermissionDeniedException from litestar.exceptions import NotAuthorizedException, PermissionDeniedException
@@ -11,7 +11,9 @@ from chitai.config import settings
class BasicAuthenticationMiddleware(AbstractAuthenticationMiddleware): class BasicAuthenticationMiddleware(AbstractAuthenticationMiddleware):
async def authenticate_request(self, connection: ASGIConnection) -> AuthenticationResult: async def authenticate_request(
self, connection: ASGIConnection
) -> AuthenticationResult:
"""Given a request, parse the header for Base64 encoded basic auth credentials.""" """Given a request, parse the header for Base64 encoded basic auth credentials."""
# retrieve the auth header # retrieve the auth header
@@ -19,11 +21,14 @@ class BasicAuthenticationMiddleware(AbstractAuthenticationMiddleware):
if not auth_header: if not auth_header:
raise NotAuthorizedException() raise NotAuthorizedException()
username, password = b64decode(auth_header.split("Basic ")[1]).decode().split(":") username, password = (
b64decode(auth_header.split("Basic ")[1]).decode().split(":")
)
try: try:
db_session = settings.alchemy_config.provide_session(connection.app.state, connection.scope) db_session = settings.alchemy_config.provide_session(
connection.app.state, connection.scope
)
user_service = UserService(db_session) user_service = UserService(db_session)
user = await user_service.authenticate(username, password) user = await user_service.authenticate(username, password)
return AuthenticationResult(user=user, auth=None) return AuthenticationResult(user=user, auth=None)
+7 -3
View File
@@ -3,7 +3,7 @@ from chitai.services.kosync_device import KosyncDeviceService
from litestar.middleware import ( from litestar.middleware import (
AbstractAuthenticationMiddleware, AbstractAuthenticationMiddleware,
AuthenticationResult, AuthenticationResult,
DefineMiddleware DefineMiddleware,
) )
from litestar.connection import ASGIConnection from litestar.connection import ASGIConnection
from litestar.exceptions import NotAuthorizedException, PermissionDeniedException from litestar.exceptions import NotAuthorizedException, PermissionDeniedException
@@ -11,7 +11,9 @@ from chitai.config import settings
class KosyncAuthenticationMiddleware(AbstractAuthenticationMiddleware): class KosyncAuthenticationMiddleware(AbstractAuthenticationMiddleware):
async def authenticate_request(self, connection: ASGIConnection) -> AuthenticationResult: async def authenticate_request(
self, connection: ASGIConnection
) -> AuthenticationResult:
"""Given a request, parse the header for Base64 encoded basic auth credentials.""" """Given a request, parse the header for Base64 encoded basic auth credentials."""
# retrieve the auth header # retrieve the auth header
@@ -20,7 +22,9 @@ class KosyncAuthenticationMiddleware(AbstractAuthenticationMiddleware):
raise NotAuthorizedException() raise NotAuthorizedException()
try: try:
db_session = settings.alchemy_config.provide_session(connection.app.state, connection.scope) db_session = settings.alchemy_config.provide_session(
connection.app.state, connection.scope
)
user_service = UserService(db_session) user_service = UserService(db_session)
device_service = KosyncDeviceService(db_session) device_service = KosyncDeviceService(db_session)
-2
View File
@@ -273,5 +273,3 @@ class BookProgressCreate(BaseModel):
completed: bool | None = None completed: bool | None = None
device_type: str | None = None device_type: str | None = None
device_id: str | None = None device_id: str | None = None
+2 -1
View File
@@ -1,9 +1,9 @@
from pathlib import Path
from typing import Annotated from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field, SkipValidation, computed_field from pydantic import BaseModel, ConfigDict, Field, SkipValidation, computed_field
from litestar.datastructures import UploadFile from litestar.datastructures import UploadFile
from advanced_alchemy.utils.text import slugify from advanced_alchemy.utils.text import slugify
class LibraryCreate(BaseModel): class LibraryCreate(BaseModel):
name: Annotated[str, Field(min_length=1)] name: Annotated[str, Field(min_length=1)]
root_path: str root_path: str
@@ -17,6 +17,7 @@ class LibraryCreate(BaseModel):
def slug(self) -> str: def slug(self) -> str:
return slugify(self.name) return slugify(self.name)
class LibraryRead(BaseModel): class LibraryRead(BaseModel):
id: int id: int
name: str name: str
+1 -1
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, Field, field_validator
class ShelfRead(BaseModel): class ShelfRead(BaseModel):
+67 -33
View File
@@ -19,7 +19,7 @@ from advanced_alchemy.service import (
SQLAlchemyAsyncRepositoryService, SQLAlchemyAsyncRepositoryService,
ModelDictT, ModelDictT,
is_dict, is_dict,
schema_dump schema_dump,
) )
from advanced_alchemy.repository import SQLAlchemyAsyncRepository from advanced_alchemy.repository import SQLAlchemyAsyncRepository
from advanced_alchemy.filters import CollectionFilter from advanced_alchemy.filters import CollectionFilter
@@ -349,7 +349,9 @@ def _series_position(data: Any) -> tuple[str, str | None]:
return normalize_title(series), (position or "").strip() or None return normalize_title(series), (position or "").strip() or None
def _is_different_volume(left: tuple[str, str | None], right: tuple[str, str | None]) -> bool: def _is_different_volume(
left: tuple[str, str | None], right: tuple[str, str | None]
) -> bool:
""" """
Whether two books are numbered entries of one series, and not the same entry. Whether two books are numbered entries of one series, and not the same entry.
@@ -464,8 +466,6 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
repository_type = Repo repository_type = Repo
async def create_book( async def create_book(
self, self,
data: ModelDictT[Book], data: ModelDictT[Book],
@@ -647,7 +647,10 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
return [] return []
statement = ( statement = (
select(Book).where(or_(*conditions)).options(*self._MATCH_LOADS).order_by(Book.id) select(Book)
.where(or_(*conditions))
.options(*self._MATCH_LOADS)
.order_by(Book.id)
) )
if exclude_book_id is not None: if exclude_book_id is not None:
@@ -724,7 +727,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
ValueError: If fewer than two distinct books were named, one of them does ValueError: If fewer than two distinct books were named, one of them does
not exist, or they do not all belong to one library. not exist, or they do not all belong to one library.
""" """
merged_ids = [book_id for book_id in dict.fromkeys(merged_ids) if book_id != survivor_id] merged_ids = [
book_id for book_id in dict.fromkeys(merged_ids) if book_id != survivor_id
]
if not merged_ids: if not merged_ids:
raise ValueError("A merge needs at least two different books") raise ValueError("A merge needs at least two different books")
@@ -848,12 +853,16 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# Reading progress is per user, and the furthest one is the true answer for a # Reading progress is per user, and the furthest one is the true answer for a
# reader who has been through the EPUB and not the PDF. # reader who has been through the EPUB and not the PDF.
rows = ( rows = (
(
await session.execute( await session.execute(
select(BookProgress) select(BookProgress)
.where(BookProgress.book_id.in_([survivor_id, *merged_ids])) .where(BookProgress.book_id.in_([survivor_id, *merged_ids]))
.order_by(BookProgress.percentage.desc()) .order_by(BookProgress.percentage.desc())
) )
).scalars().all() )
.scalars()
.all()
)
furthest: dict[int, BookProgress] = {} furthest: dict[int, BookProgress] = {}
for progress in rows: for progress in rows:
@@ -902,14 +911,19 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# and only one of them however many books offered it. # and only one of them however many books offered it.
held_names = select(Identifier.name).where(Identifier.book_id == survivor_id) held_names = select(Identifier.name).where(Identifier.book_id == survivor_id)
incoming = ( incoming = (
(
await session.execute( await session.execute(
select(Identifier) select(Identifier)
.where( .where(
Identifier.book_id.in_(merged_ids), Identifier.name.notin_(held_names) Identifier.book_id.in_(merged_ids),
Identifier.name.notin_(held_names),
) )
.order_by(Identifier.book_id, Identifier.id) .order_by(Identifier.book_id, Identifier.id)
) )
).scalars().all() )
.scalars()
.all()
)
taken: set[str] = set() taken: set[str] = set()
for identifier in incoming: for identifier in incoming:
@@ -932,6 +946,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
merged = set(merged_ids) merged = set(merged_ids)
rows = ( rows = (
(
await session.execute( await session.execute(
select(DuplicateDismissal).where( select(DuplicateDismissal).where(
or_( or_(
@@ -940,7 +955,10 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
) )
) )
) )
).scalars().all() )
.scalars()
.all()
)
existing = await self._dismissed_pairs() existing = await self._dismissed_pairs()
doomed: list[int] = [] doomed: list[int] = []
@@ -1025,7 +1043,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# copies meet as long as they agree on any one of them. # copies meet as long as they agree on any one of them.
for title in titles: for title in titles:
for author in authors: for author in authors:
buckets[(MATCHED_ON_TITLE_AUTHOR, f"{title}\x00{author}")].append(book_id) buckets[(MATCHED_ON_TITLE_AUTHOR, f"{title}\x00{author}")].append(
book_id
)
dismissed = await self._dismissed_pairs() dismissed = await self._dismissed_pairs()
series = {book_id: _series_position(book) for book_id, book in books.items()} series = {book_id: _series_position(book) for book_id, book in books.items()}
@@ -1071,8 +1091,14 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
raise ValueError("A book cannot be dismissed against itself") raise ValueError("A book cannot be dismissed against itself")
found = ( found = (
await self.repository.session.execute(select(Book.id).where(Book.id.in_(pair))) (
).scalars().all() await self.repository.session.execute(
select(Book.id).where(Book.id.in_(pair))
)
)
.scalars()
.all()
)
if len(set(found)) != 2: if len(set(found)) != 2:
raise ValueError("No such book") raise ValueError("No such book")
@@ -1305,7 +1331,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
**kwargs, **kwargs,
) )
result.books.append(book) result.books.append(book)
await self._record_possible_duplicates(result.possible_duplicates, book, library) await self._record_possible_duplicates(
result.possible_duplicates, book, library
)
return result return result
@@ -1344,7 +1372,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
consume_path: Path, consume_path: Path,
library: Library, library: Library,
allow_duplicates: bool = False, allow_duplicates: bool = False,
**kwargs **kwargs,
) -> ImportResult: ) -> ImportResult:
""" """
Import files that are already on disk, from the consume directory. Import files that are already on disk, from the consume directory.
@@ -1368,7 +1396,6 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
result = ImportResult() result = ImportResult()
file_groups: dict[Path, list[Path]] = defaultdict(list) file_groups: dict[Path, list[Path]] = defaultdict(list)
for file_path in file_paths: for file_path in file_paths:
rel_path = file_path.relative_to(consume_path) rel_path = file_path.relative_to(consume_path)
parent_rel = rel_path.parent parent_rel = rel_path.parent
@@ -1383,7 +1410,6 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# Add to appropriate group # Add to appropriate group
file_groups[group_key].append(file_path) file_groups[group_key].append(file_path)
# For each grouping # For each grouping
for group, files in file_groups.items(): for group, files in file_groups.items():
# Fingerprinted before anything moves, since the paths are about to change. # Fingerprinted before anything moves, since the paths are about to change.
@@ -1405,7 +1431,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
cleanup_empty_parent_directories(consume_path / group, consume_path) cleanup_empty_parent_directories(consume_path / group, consume_path)
continue continue
data: dict[str, Any] = {'files': accepted} data: dict[str, Any] = {"files": accepted}
await self._parse_metadata_from_files(data, root_path=consume_path) await self._parse_metadata_from_files(data, root_path=consume_path)
await self._save_cover_image(data) await self._save_cover_image(data)
@@ -1446,7 +1472,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
book = await super().create(data) book = await super().create(data)
result.books.append(book) result.books.append(book)
await self._record_possible_duplicates(result.possible_duplicates, book, library) await self._record_possible_duplicates(
result.possible_duplicates, book, library
)
await self.repository.session.commit() await self.repository.session.commit()
@@ -1554,17 +1582,11 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# What the catalogue claims and what is on disk can disagree: Calibre keeps the # What the catalogue claims and what is on disk can disagree: Calibre keeps the
# row when a file is moved away behind its back. # row when a file is moved away behind its back.
present = [ present = [
file.path file.path for file in entry.files if await aios.path.isfile(file.path)
for file in entry.files
if await aios.path.isfile(file.path)
] ]
if not present: if not present:
reason = ( reason = "no files on disk" if entry.files else "no files in the catalogue"
"no files on disk"
if entry.files
else "no files in the catalogue"
)
result.skipped.append( result.skipped.append(
UnimportedBook( UnimportedBook(
calibre_id=entry.calibre_id, title=entry.title, reason=reason calibre_id=entry.calibre_id, title=entry.title, reason=reason
@@ -1918,9 +1940,13 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# TODO: Move only the files associated with the book instead of the whole directory # TODO: Move only the files associated with the book instead of the whole directory
await move_dir_contents(book.path, updated_path) await move_dir_contents(book.path, updated_path)
data["path"] = str(updated_path) data["path"] = str(updated_path)
cleanup_empty_parent_directories(Path(book.path), Path(library.root_path)) cleanup_empty_parent_directories(
Path(book.path), Path(library.root_path)
)
return await super().update(data, item_id=book_id, execution_options={"populate_existing": True}) return await super().update(
data, item_id=book_id, execution_options={"populate_existing": True}
)
async def add_files( async def add_files(
self, self,
@@ -1979,7 +2005,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
data["files"] = files data["files"] = files
new_files = await self._save_book_files(library, data, fingerprints) new_files = await self._save_book_files(library, data, fingerprints)
book.files.extend(new_files) book.files.extend(new_files)
await self.update_book(book.id, {"files": [file for file in book.files]}, library) await self.update_book(
book.id, {"files": [file for file in book.files]}, library
)
@staticmethod @staticmethod
async def _restore_file( async def _restore_file(
@@ -2082,7 +2110,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
return data return data
async def _populate_with_unique_relationships(self, data: ModelDictT[Book]) -> ModelDictT[Book]: async def _populate_with_unique_relationships(
self, data: ModelDictT[Book]
) -> ModelDictT[Book]:
""" """
Ensure relationship entities (authors, series, tags, etc.) are unique in the database. Ensure relationship entities (authors, series, tags, etc.) are unique in the database.
@@ -2307,7 +2337,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
data["files"] = file_metadata data["files"] = file_metadata
return data["files"] return data["files"]
async def _parse_metadata_from_files(self, data: dict, root_path: Path | None = None) -> dict: async def _parse_metadata_from_files(
self, data: dict, root_path: Path | None = None
) -> dict:
""" """
Extract metadata (title, author, etc.) from book files. Extract metadata (title, author, etc.) from book files.
@@ -2319,7 +2351,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
Returns: Returns:
The data with extracted metadata populated in empty fields. The data with extracted metadata populated in empty fields.
""" """
extracted_metadata = await MetadataExtractor.extract_metadata(data["files"], root_path) extracted_metadata = await MetadataExtractor.extract_metadata(
data["files"], root_path
)
# Add missing fields and update empty (falsey) fields with extracted metadata # Add missing fields and update empty (falsey) fields with extracted metadata
for attr in extracted_metadata.keys(): for attr in extracted_metadata.keys():
-5
View File
@@ -1,13 +1,8 @@
# src/chitai/services/bookshelf.py # src/chitai/services/bookshelf.py
# Third-party libraries # Third-party libraries
from typing import Any, Sequence
from advanced_alchemy.exceptions import ErrorMessages
from advanced_alchemy.filters import StatementFilter
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
from advanced_alchemy.repository import SQLAlchemyAsyncRepository from advanced_alchemy.repository import SQLAlchemyAsyncRepository
from advanced_alchemy.utils.dataclass import Empty, EmptyType
from sqlalchemy import ColumnElement, Select, delete
# Local imports # Local imports
from chitai.database.models.book_list import BookList, BookListLink from chitai.database.models.book_list import BookList, BookListLink
+19 -4
View File
@@ -297,7 +297,9 @@ class CalibreLibrary:
async def count(self) -> int: async def count(self) -> int:
"""How many books the catalogue holds, without reading any of them.""" """How many books the catalogue holds, without reading any of them."""
rows = await self._in_thread(lambda: self._execute("SELECT count(*) FROM books")) rows = await self._in_thread(
lambda: self._execute("SELECT count(*) FROM books")
)
return int(rows[0][0]) return int(rows[0][0])
async def books(self) -> list[CalibreBook]: async def books(self) -> list[CalibreBook]:
@@ -393,7 +395,9 @@ class CalibreLibrary:
calibre_id=book_id, calibre_id=book_id,
uuid=str(uuid or ""), uuid=str(uuid or ""),
title=str(title or ""), title=str(title or ""),
authors=[unescape_author(name) for name in authors.get(book_id, [])], authors=[
unescape_author(name) for name in authors.get(book_id, [])
],
description=strip_html(descriptions.get(book_id)), description=strip_html(descriptions.get(book_id)),
published_date=parse_date(pubdate), published_date=parse_date(pubdate),
series=in_series, series=in_series,
@@ -510,8 +514,19 @@ class _TextExtractor(HTMLParser):
# a description of three paragraphs comes out as one run-on sentence. # a description of three paragraphs comes out as one run-on sentence.
_BREAKS = frozenset( _BREAKS = frozenset(
{ {
"p", "br", "div", "li", "tr", "blockquote", "hr", "p",
"h1", "h2", "h3", "h4", "h5", "h6", "br",
"div",
"li",
"tr",
"blockquote",
"hr",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
} }
) )
+8 -4
View File
@@ -4,14 +4,20 @@ from collections import defaultdict
from chitai.config import settings from chitai.config import settings
from chitai.database.models.library import Library from chitai.database.models.library import Library
from chitai.services import BookService, LibraryService from chitai.services import BookService, LibraryService
from chitai.services.metadata_extractor import Extractor
from chitai.services.utils import create_directory from chitai.services.utils import create_directory
from watchfiles import awatch, Change from watchfiles import awatch, Change
class ConsumeDirectoryWatcher: class ConsumeDirectoryWatcher:
"""Watches a directory and batch processes files by their relative path.""" """Watches a directory and batch processes files by their relative path."""
def __init__(self, watch_path: str, library_service: LibraryService, book_service: BookService, batch_delay: float = 3.0): def __init__(
self,
watch_path: str,
library_service: LibraryService,
book_service: BookService,
batch_delay: float = 3.0,
):
""" """
Initialize the file watcher. Initialize the file watcher.
@@ -111,7 +117,6 @@ class ConsumeDirectoryWatcher:
async def _process_batch(self, file_paths: set[Path], library_slug: str): async def _process_batch(self, file_paths: set[Path], library_slug: str):
"""Process a batch of files.""" """Process a batch of files."""
try: try:
result = await self.book_service.create_many_from_existing_files( result = await self.book_service.create_many_from_existing_files(
list(file_paths), list(file_paths),
self.watch_path / Path(library_slug), self.watch_path / Path(library_slug),
@@ -138,7 +143,6 @@ class ConsumeDirectoryWatcher:
f"already be in the library as: {names}" f"already be in the library as: {names}"
) )
except Exception as e: except Exception as e:
print(f"Error processing batch: {e}") print(f"Error processing batch: {e}")
raise e raise e
+2 -5
View File
@@ -2,7 +2,7 @@
# Standard library # Standard library
from __future__ import annotations from __future__ import annotations
from typing import Any, AsyncGenerator, Callable, NotRequired, Optional from typing import Any, AsyncGenerator, Optional
# Third-party libraries # Third-party libraries
from advanced_alchemy.extensions.litestar.providers import ( from advanced_alchemy.extensions.litestar.providers import (
@@ -13,7 +13,6 @@ from advanced_alchemy.extensions.litestar.providers import (
) )
from advanced_alchemy.exceptions import NotFoundError from advanced_alchemy.exceptions import NotFoundError
from advanced_alchemy.filters import CollectionFilter, StatementFilter from advanced_alchemy.filters import CollectionFilter, StatementFilter
from advanced_alchemy.service import FilterTypeT
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from litestar import Request from litestar import Request
@@ -25,7 +24,6 @@ from litestar.di import Provide
from advanced_alchemy.extensions.litestar.providers import create_filter_dependencies from advanced_alchemy.extensions.litestar.providers import create_filter_dependencies
# Local imports # Local imports
from chitai import schemas as s
from chitai.database import models as m from chitai.database import models as m
from chitai.services import ( from chitai.services import (
UserService, UserService,
@@ -154,7 +152,6 @@ def create_book_filter_dependencies(
# OVERRIDE: Custom search filter with trigram search # OVERRIDE: Custom search filter with trigram search
if config.get("search"): if config.get("search"):
search_fields = config.get("search")
def provide_trigram_search_filter( def provide_trigram_search_filter(
search_string: str | None = Parameter( search_string: str | None = Parameter(
@@ -370,6 +367,7 @@ def provide_optional_user(request: Request[m.User, Token, Any]) -> m.User | None
return None return None
async def provide_user_via_basic_auth(request: Request[m.User, None, Any]) -> m.User: async def provide_user_via_basic_auth(request: Request[m.User, None, Any]) -> m.User:
return request.user return request.user
@@ -381,4 +379,3 @@ async def provide_user_via_kosync_auth(request: Request[m.User, None, Any]) -> m
provide_kosync_device_service = create_service_provider(KosyncDeviceService) provide_kosync_device_service = create_service_provider(KosyncDeviceService)
provide_kosync_progress_service = create_service_provider(KosyncProgressService) provide_kosync_progress_service = create_service_provider(KosyncProgressService)
@@ -4,9 +4,7 @@ from pathlib import Path
import re import re
from jinja2 import Template from jinja2 import Template
from advanced_alchemy.service import ModelDictT
import chitai.database.models as m
# TODO: Replace Jinja2 templates with a simpler custom templating system. # TODO: Replace Jinja2 templates with a simpler custom templating system.
# Current Jinja2 implementation is overly complex for basic path generation. # Current Jinja2 implementation is overly complex for basic path generation.
@@ -1,4 +1,4 @@
from typing import Any, Optional from typing import Optional
from dataclasses import dataclass from dataclasses import dataclass
from advanced_alchemy.filters import ( from advanced_alchemy.filters import (
+9 -5
View File
@@ -3,11 +3,10 @@ from typing import Any, Optional
from dataclasses import dataclass, field from dataclasses import dataclass, field
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from sqlalchemy import Select, and_, desc, func, or_, text from sqlalchemy import Select, and_, func, or_, text
from advanced_alchemy.filters import ( from advanced_alchemy.filters import (
StatementTypeT, StatementTypeT,
StatementFilter, StatementFilter,
CollectionFilter,
ModelT, ModelT,
) )
@@ -135,7 +134,7 @@ class ProgressFilter(StatementFilter):
status_conditions.append( status_conditions.append(
and_( and_(
or_( or_(
m.BookProgress.completed == False, m.BookProgress.completed.is_(False),
m.BookProgress.completed.is_(None), m.BookProgress.completed.is_(None),
), ),
m.BookProgress.percentage > 0, m.BookProgress.percentage > 0,
@@ -143,7 +142,7 @@ class ProgressFilter(StatementFilter):
) )
if ProgressStatus.READ in self.statuses: if ProgressStatus.READ in self.statuses:
status_conditions.append(m.BookProgress.completed == True) status_conditions.append(m.BookProgress.completed.is_(True))
if ProgressStatus.UNREAD in self.statuses: if ProgressStatus.UNREAD in self.statuses:
status_conditions.append(m.BookProgress.id.is_(None)) status_conditions.append(m.BookProgress.id.is_(None))
@@ -154,6 +153,7 @@ class ProgressFilter(StatementFilter):
@dataclass @dataclass
class FileFilter(StatementFilter): class FileFilter(StatementFilter):
"""Filter books that are related to the given files.""" """Filter books that are related to the given files."""
file_ids: list[int] file_ids: list[int]
def append_to_statement( def append_to_statement(
@@ -165,17 +165,21 @@ class FileFilter(StatementFilter):
return super().append_to_statement(statement, model, *args, **kwargs) return super().append_to_statement(statement, model, *args, **kwargs)
@dataclass @dataclass
class FileHashFilter(StatementFilter): class FileHashFilter(StatementFilter):
file_hashes: list[str] file_hashes: list[str]
def append_to_statement(self, statement: StatementTypeT, model: type[ModelT], *args, **kwargs) -> StatementTypeT: def append_to_statement(
self, statement: StatementTypeT, model: type[ModelT], *args, **kwargs
) -> StatementTypeT:
statement = statement.where( statement = statement.where(
m.Book.files.any(m.FileMetadata.hash.in_(self.file_hashes)) m.Book.files.any(m.FileMetadata.hash.in_(self.file_hashes))
) )
return super().append_to_statement(statement, model, *args, **kwargs) return super().append_to_statement(statement, model, *args, **kwargs)
@dataclass @dataclass
class CustomOrderBy(StatementFilter): class CustomOrderBy(StatementFilter):
"""Order by filter with support for 'random' and 'last accessed' orderings.""" """Order by filter with support for 'random' and 'last accessed' orderings."""
+7 -3
View File
@@ -1,9 +1,14 @@
from __future__ import annotations from __future__ import annotations
import secrets import secrets
from chitai.database.models.kosync_device import KosyncDevice from chitai.database.models.kosync_device import KosyncDevice
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService, ModelDictT, schema_dump from advanced_alchemy.service import (
SQLAlchemyAsyncRepositoryService,
ModelDictT,
schema_dump,
)
from advanced_alchemy.repository import SQLAlchemyAsyncRepository from advanced_alchemy.repository import SQLAlchemyAsyncRepository
class KosyncDeviceService(SQLAlchemyAsyncRepositoryService[KosyncDevice]): class KosyncDeviceService(SQLAlchemyAsyncRepositoryService[KosyncDevice]):
"""Service for managing KOReader devices.""" """Service for managing KOReader devices."""
@@ -18,7 +23,7 @@ class KosyncDeviceService(SQLAlchemyAsyncRepositoryService[KosyncDevice]):
async def create(self, data: ModelDictT[KosyncDevice], **kwargs) -> KosyncDevice: async def create(self, data: ModelDictT[KosyncDevice], **kwargs) -> KosyncDevice:
data = schema_dump(data) data = schema_dump(data)
data['api_key'] = self._generate_api_key() data["api_key"] = self._generate_api_key()
return await super().create(data, **kwargs) return await super().create(data, **kwargs)
async def get_by_api_key(self, api_key: str) -> KosyncDevice: async def get_by_api_key(self, api_key: str) -> KosyncDevice:
@@ -30,6 +35,5 @@ class KosyncDeviceService(SQLAlchemyAsyncRepositoryService[KosyncDevice]):
device.api_key = api_key device.api_key = api_key
return await self.update(device) return await self.update(device)
def _generate_api_key(self) -> str: def _generate_api_key(self) -> str:
return secrets.token_hex(self.API_KEY_LENGTH_IN_BYTES) return secrets.token_hex(self.API_KEY_LENGTH_IN_BYTES)
@@ -16,7 +16,9 @@ class KosyncProgressService(SQLAlchemyAsyncRepositoryService[KosyncProgress]):
repository_type = Repo repository_type = Repo
async def get_by_document_hash(self, user_id: int, document: str) -> KosyncProgress | None: async def get_by_document_hash(
self, user_id: int, document: str
) -> KosyncProgress | None:
"""Get progress for a specific document and user.""" """Get progress for a specific document and user."""
return await self.get_one_or_none( return await self.get_one_or_none(
KosyncProgress.user_id == user_id, KosyncProgress.user_id == user_id,
+2 -6
View File
@@ -5,7 +5,6 @@ from pathlib import Path
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
from advanced_alchemy.repository import SQLAlchemyAsyncRepository from advanced_alchemy.repository import SQLAlchemyAsyncRepository
from advanced_alchemy import service from advanced_alchemy import service
from advanced_alchemy.utils.text import slugify
# Local imports # Local imports
from chitai.database.models.library import Library from chitai.database.models.library import Library
@@ -18,6 +17,7 @@ from chitai.services.utils import (
from chitai.config import settings from chitai.config import settings
class LibraryService(SQLAlchemyAsyncRepositoryService[Library]): class LibraryService(SQLAlchemyAsyncRepositoryService[Library]):
"""Service for managing libraries and their configuration.""" """Service for managing libraries and their configuration."""
@@ -48,7 +48,7 @@ class LibraryService(SQLAlchemyAsyncRepositoryService[Library]):
""" """
# TODO: What if a library root_path is a child of an existing library? # TODO: What if a library root_path is a child of an existing library?
if existing := await self.list(Library.root_path == library.root_path): if await self.list(Library.root_path == library.root_path):
raise ValueError(f"Library already exists at '{library.root_path}'") raise ValueError(f"Library already exists at '{library.root_path}'")
if library.read_only: if library.read_only:
@@ -58,7 +58,6 @@ class LibraryService(SQLAlchemyAsyncRepositoryService[Library]):
f"Root directory '{library.root_path}' must exist for a read-only library" f"Root directory '{library.root_path}' must exist for a read-only library"
) )
# TODO: Verify the read-only library has read permissions # TODO: Verify the read-only library has read permissions
created_library = await super().create( created_library = await super().create(
service.schema_dump(library, exclude_unset=False), **kwargs service.schema_dump(library, exclude_unset=False), **kwargs
@@ -72,9 +71,6 @@ class LibraryService(SQLAlchemyAsyncRepositoryService[Library]):
return created_library return created_library
# TODO: Implement library deletion and optional file deletion # TODO: Implement library deletion and optional file deletion
async def delete( async def delete(
self, item_id: int, delete_files: bool = False, **kwargs self, item_id: int, delete_files: bool = False, **kwargs
@@ -103,8 +103,18 @@ def parse_identifier(value: str, scheme: str | None = None) -> tuple[str, str] |
# Numbered editions, in the forms covers and catalogue records actually use: # Numbered editions, in the forms covers and catalogue records actually use:
# "3rd Edition", "2E", "4e", "8_e", "/6e", "(2nd edition)", "Third International Edition". # "3rd Edition", "2E", "4e", "8_e", "/6e", "(2nd edition)", "Third International Edition".
_ORDINAL_WORDS = { _ORDINAL_WORDS = {
"first": 1, "second": 2, "third": 3, "fourth": 4, "fifth": 5, "sixth": 6, "first": 1,
"seventh": 7, "eighth": 8, "ninth": 9, "tenth": 10, "eleventh": 11, "twelfth": 12, "second": 2,
"third": 3,
"fourth": 4,
"fifth": 5,
"sixth": 6,
"seventh": 7,
"eighth": 8,
"ninth": 9,
"tenth": 10,
"eleventh": 11,
"twelfth": 12,
} }
# Words that sit between the number and "Edition" and belong to the same statement. # Words that sit between the number and "Edition" and belong to the same statement.
@@ -161,7 +171,9 @@ def split_edition(title: str | None) -> tuple[str | None, int | None]:
stripped = _EDITION.sub(" ", title) stripped = _EDITION.sub(" ", title)
stripped = re.sub(r"\s{2,}", " ", stripped) stripped = re.sub(r"\s{2,}", " ", stripped)
stripped = re.sub(r"\s+([,;:.!?])", r"\1", stripped) # "Works : What" → "Works: What" stripped = re.sub(
r"\s+([,;:.!?])", r"\1", stripped
) # "Works : What" → "Works: What"
stripped = stripped.strip(" ,;:-–—/") stripped = stripped.strip(" ,;:-–—/")
# A title that is only an edition statement is not improved by having none. # A title that is only an edition statement is not improved by having none.
@@ -178,7 +190,9 @@ class FileExtractor(Protocol):
) -> dict[str, Any]: ... ) -> dict[str, Any]: ...
@classmethod @classmethod
async def extract_text(cls, input: UploadFile | BinaryIO | bytes | Path | str) -> str: ... async def extract_text(
cls, input: UploadFile | BinaryIO | bytes | Path | str
) -> str: ...
class Extractor: class Extractor:
@@ -187,7 +201,9 @@ class Extractor:
format_priorities = {"epub": 1, "pdf": 2} format_priorities = {"epub": 1, "pdf": 2}
@classmethod @classmethod
async def extract_metadata(cls, files: list[UploadFile] | list[Path], root_path: Path | None = None) -> dict[str, Any]: async def extract_metadata(
cls, files: list[UploadFile] | list[Path], root_path: Path | None = None
) -> dict[str, Any]:
metadata = {} metadata = {}
# Sort based on file priority # Sort based on file priority
@@ -235,7 +251,7 @@ class Extractor:
metadata = FilepathExtractor.extract_metadata(files[0], root_path) | metadata metadata = FilepathExtractor.extract_metadata(files[0], root_path) | metadata
# format the title # format the title
if metadata.get('title', None): if metadata.get("title", None):
# Before the subtitle split, so the edition cannot be mistaken for one: # Before the subtitle split, so the edition cannot be mistaken for one:
# "How Linux Works, 3rd Edition: What Every Superuser Should Know" has to # "How Linux Works, 3rd Edition: What Every Superuser Should Know" has to
# lose the edition first for the colon count to mean anything. # lose the edition first for the colon count to mean anything.
@@ -282,7 +298,7 @@ class Extractor:
file_ext = get_file_extension(filename) file_ext = get_file_extension(filename)
if file_ext is None: if file_ext is None:
return float('inf') return float("inf")
return Extractor.format_priorities.get(file_ext, float("inf")) return Extractor.format_priorities.get(file_ext, float("inf"))
@@ -618,7 +634,9 @@ class FilepathExtractor(FileExtractor):
"""Extracts metadata from the filepath.""" """Extracts metadata from the filepath."""
@classmethod @classmethod
def extract_metadata(cls, input: UploadFile | Path | str, root_path: Path | None = None) -> dict[str, Any]: def extract_metadata(
cls, input: UploadFile | Path | str, root_path: Path | None = None
) -> dict[str, Any]:
if isinstance(input, UploadFile): if isinstance(input, UploadFile):
path = Path(input.filename).parent path = Path(input.filename).parent
@@ -633,27 +651,28 @@ class FilepathExtractor(FileExtractor):
if len(parts) == 3: if len(parts) == 3:
# Format: Author/Series/Part - Title/filename # Format: Author/Series/Part - Title/filename
metadata['author'] = parts[0] metadata["author"] = parts[0]
# Extract part number and title from directory name (parts[2]) # Extract part number and title from directory name (parts[2])
dirname = parts[2] dirname = parts[2]
match = re.match(r'^([\d.]+)\s*-\s*(.+)$', dirname) match = re.match(r"^([\d.]+)\s*-\s*(.+)$", dirname)
if match: if match:
metadata['series_position'] = match.group(1) # Keep as string metadata["series_position"] = match.group(1) # Keep as string
metadata['series'] = parts[1] metadata["series"] = parts[1]
metadata['title'] = match.group(2).strip() metadata["title"] = match.group(2).strip()
else: else:
metadata['series'] = parts[1] metadata["series"] = parts[1]
metadata['title'] = path.stem metadata["title"] = path.stem
elif len(parts) == 2: elif len(parts) == 2:
# Format: Author/Title # Format: Author/Title
metadata['author'] = parts[0] metadata["author"] = parts[0]
metadata['title'] = path.stem # Remove extension metadata["title"] = path.stem # Remove extension
return metadata return metadata
class FilenameExtractor(FileExtractor): class FilenameExtractor(FileExtractor):
"""Extracts metadata from the filename.""" """Extracts metadata from the filename."""
+25 -11
View File
@@ -4,25 +4,37 @@ from typing import Any, Literal, Optional, Sequence
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from datetime import datetime from datetime import datetime
class LinkTypes(StrEnum): class LinkTypes(StrEnum):
NAVIGATION = "application/atom+xml;profile=opds-catalog;kind=navigation" NAVIGATION = "application/atom+xml;profile=opds-catalog;kind=navigation"
ACQUISITION = "application/atom+xml;profile=opds-catalog;kind=acquisition" ACQUISITION = "application/atom+xml;profile=opds-catalog;kind=acquisition"
OPEN_SEARCH = "application/opensearchdescription+xml" OPEN_SEARCH = "application/opensearchdescription+xml"
class AcquisitionRelations(StrEnum): class AcquisitionRelations(StrEnum):
_BASE = "http://opds-spec.org/acquisition" _BASE = "http://opds-spec.org/acquisition"
ACQUISITION = _BASE # A generic relation that indicates that the entry may be retrieved ACQUISITION = (
OPEN_ACCESS = f"{_BASE}/open-access" # Entry may be retrieved without any requirement _BASE # A generic relation that indicates that the entry may be retrieved
BORROW = f"{_BASE}/borrow" # Entry may be retrieved as part of a lending transaction )
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 BUY = f"{_BASE}/buy" # Entry may be retrieved as part of a purchase
SAMPLE = f"{_BASE}/sample" # Subset of the entry may be retrieved SAMPLE = f"{_BASE}/sample" # Subset of the entry may be retrieved
PREVIEW = f"{_BASE}/preview" # 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 SUBSCRIBE = (
f"{_BASE}/subscribe" # Entry my be retrieved as a part of a subscription
)
class NavigationRelations(StrEnum): class NavigationRelations(StrEnum):
_BASE = "" _BASE = ""
class LinkRelations(StrEnum): class LinkRelations(StrEnum):
"""Link types for OPDSv1.2 related resources """Link types for OPDSv1.2 related resources
@@ -32,17 +44,19 @@ class LinkRelations(StrEnum):
_BASE = "http://opds-spec.org" _BASE = "http://opds-spec.org"
START = "start" # The OPDS catalog root START = "start" # The OPDS catalog root
SUBSECTION = "subsection" # an OPDS feed not better described by any of the below relations SUBSECTION = (
"subsection" # an OPDS feed not better described by any of the below relations
)
SHELF = f"{_BASE}/shelf" # Entries acquired by the euser SHELF = f"{_BASE}/shelf" # Entries acquired by the euser
SUBSCRIPTIONS = f"{_BASE}/subscriptions" # Entries available with users's subscription SUBSCRIPTIONS = (
f"{_BASE}/subscriptions" # Entries available with users's subscription
)
NEW = f"{_BASE}/sort/new" # Newest entries NEW = f"{_BASE}/sort/new" # Newest entries
POPULAR = f"{_BASE}/sort/popular" # Most popular entries POPULAR = f"{_BASE}/sort/popular" # Most popular entries
FEATURED = f"{_BASE}/featured" # Entries selected for promotion FEATURED = f"{_BASE}/featured" # Entries selected for promotion
RECOMMENDED = f"{_BASE}/recommended" # Entries recommended to the specific user RECOMMENDED = f"{_BASE}/recommended" # Entries recommended to the specific user
class Feed(BaseModel): # OPDS Catalog root element class Feed(BaseModel): # OPDS Catalog root element
xmlns: Literal["http://www.w3.org/2005/Atom"] = Field( xmlns: Literal["http://www.w3.org/2005/Atom"] = Field(
default="http://www.w3.org/2005/Atom", serialization_alias="@xmlns" default="http://www.w3.org/2005/Atom", serialization_alias="@xmlns"
@@ -117,9 +131,8 @@ class AcquisitionFeedLink(Link):
class NavigationFeedLink(Link): class NavigationFeedLink(Link):
type: str = Field( type: str = Field(default=LinkTypes.NAVIGATION, serialization_alias="@type")
default=LinkTypes.NAVIGATION, serialization_alias="@type"
)
class Content(BaseModel): class Content(BaseModel):
type: Literal["text"] = Field(default="text", serialization_alias="@type") type: Literal["text"] = Field(default="text", serialization_alias="@type")
@@ -170,6 +183,7 @@ class Entry(BaseModel):
data = super().model_dump(**kwargs) data = super().model_dump(**kwargs)
return {"entry": data} return {"entry": data}
@dataclass @dataclass
class PaginationResult: class PaginationResult:
next_link: Optional[Link] next_link: Optional[Link]
+40 -53
View File
@@ -1,4 +1,3 @@
from typing import Any, Callable, Sequence from typing import Any, Callable, Sequence
from urllib.parse import quote_plus, urlencode from urllib.parse import quote_plus, urlencode
from litestar import Request from litestar import Request
@@ -16,9 +15,10 @@ from .models import (
AcquisitionFeedLink, AcquisitionFeedLink,
NavigationFeed, NavigationFeed,
NavigationFeedLink, NavigationFeedLink,
PaginationResult PaginationResult,
) )
def get_opensearch_document(base_url: str = "/opds/search?") -> str: def get_opensearch_document(base_url: str = "/opds/search?") -> str:
search = { search = {
"OpenSearchDescription": { "OpenSearchDescription": {
@@ -120,19 +120,20 @@ def create_navigation_feed(
pretty=True, pretty=True,
) )
def create_library_navigation_feed(library: m.Library) -> str: def create_library_navigation_feed(library: m.Library) -> str:
entries = [ entries = [
Entry( Entry(
id=f"/opds/library/{library.id}/all-books", id=f"/opds/library/{library.id}/all-books",
title='All Books', title="All Books",
link=[ link=[
AcquisitionFeedLink( AcquisitionFeedLink(
rel="subsection", rel="subsection",
href=f"/opds/acquisition?libraries={library.id}&paginated=1&pageSize=50&feed_title=AllBooks&feed_id=/opds/library/{library.id}/all-books", href=f"/opds/acquisition?libraries={library.id}&paginated=1&pageSize=50&feed_title=AllBooks&feed_id=/opds/library/{library.id}/all-books",
title="All Books", title="All Books",
) )
] ],
), ),
Entry( Entry(
id=f"/opds/library/{library.id}/recently-added", id=f"/opds/library/{library.id}/recently-added",
@@ -141,9 +142,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink( NavigationFeedLink(
rel="http://opds-spec.org/sort/new", rel="http://opds-spec.org/sort/new",
href=f"/opds/acquisition?libraries={library.id}&orderBy=created_at&pageSize=50&feed_title=RecentlyAdded&feed_id=/opds/library/{library.id}/recently-added", href=f"/opds/acquisition?libraries={library.id}&orderBy=created_at&pageSize=50&feed_title=RecentlyAdded&feed_id=/opds/library/{library.id}/recently-added",
title="Recently Added" title="Recently Added",
) )
] ],
), ),
Entry( Entry(
id=f"/opds/library/{library.id}/shelves", id=f"/opds/library/{library.id}/shelves",
@@ -152,9 +153,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink( NavigationFeedLink(
rel="subsection", rel="subsection",
href=f"/opds/library/{library.id}/shelves?paginated=1&pageSize=10", href=f"/opds/library/{library.id}/shelves?paginated=1&pageSize=10",
title="Bookshelves" title="Bookshelves",
) )
] ],
), ),
Entry( Entry(
id=f"/opds/library/{library.id}/tags", id=f"/opds/library/{library.id}/tags",
@@ -163,9 +164,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink( NavigationFeedLink(
rel="subsection", rel="subsection",
href=f"/opds/library/{library.id}/tags?paginated=1&pageSize=10", href=f"/opds/library/{library.id}/tags?paginated=1&pageSize=10",
title="Tags" title="Tags",
) )
] ],
), ),
Entry( Entry(
id=f"/opds/library/{library.id}/authors", id=f"/opds/library/{library.id}/authors",
@@ -174,9 +175,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink( NavigationFeedLink(
rel="subsection", rel="subsection",
href=f"/opds/library/{library.id}/authors?paginated=1&pageSize=10", href=f"/opds/library/{library.id}/authors?paginated=1&pageSize=10",
title="Authors" title="Authors",
) )
] ],
), ),
Entry( Entry(
id=f"/opds/library/{library.id}/publishers", id=f"/opds/library/{library.id}/publishers",
@@ -185,32 +186,32 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink( NavigationFeedLink(
rel="subsection", rel="subsection",
href=f"/opds/library/{library.id}/publishers?paginated=1&pageSize=10", href=f"/opds/library/{library.id}/publishers?paginated=1&pageSize=10",
title="Publishers" title="Publishers",
) )
] ],
), ),
] ]
feed = create_navigation_feed( feed = create_navigation_feed(
id=f'/library/{library.id}', id=f"/library/{library.id}",
title=library.name, title=library.name,
self_url=f'/opds/library/{library.id}', self_url=f"/opds/library/{library.id}",
links=[ links=[],
entries=entries,
],
entries=entries
) )
return feed return feed
def create_collection_navigation_feed( def create_collection_navigation_feed(
library: m.Library, library: m.Library,
collection_type: str, collection_type: str,
items: Sequence[m.BookList | m.Tag | m.Author | m.Publisher | m.BookSeries], items: Sequence[m.BookList | m.Tag | m.Author | m.Publisher | m.BookSeries],
links: list[Link] = list(), links: list[Link] = list(),
# Title is usually derived from the model's name or title # Title is usually derived from the model's name or title
get_title: Callable[[Any], str] = lambda x: getattr(x, 'title', getattr(x, 'name', str(x))) get_title: Callable[[Any], str] = lambda x: getattr(
x, "title", getattr(x, "name", str(x))
),
) -> str: ) -> str:
entries = [ entries = [
@@ -222,52 +223,42 @@ def create_collection_navigation_feed(
href=f"/opds/acquisition?{collection_type}={item.id}&pageSize=50&paginated=1&feed_title={quote_plus(get_title(item))}&feed_id=/opds/library/{library.id}/{collection_type}/{item.id}&search=True", href=f"/opds/acquisition?{collection_type}={item.id}&pageSize=50&paginated=1&feed_title={quote_plus(get_title(item))}&feed_id=/opds/library/{library.id}/{collection_type}/{item.id}&search=True",
title=get_title(item), title=get_title(item),
) )
] ],
) for item in items )
for item in items
] ]
return create_navigation_feed( return create_navigation_feed(
id=f"/opds/library/{library.id}/{collection_type}", id=f"/opds/library/{library.id}/{collection_type}",
title=collection_type.title(), title=collection_type.title(),
self_url=f'/opds/library/{library.id}/{collection_type}', self_url=f"/opds/library/{library.id}/{collection_type}",
entries=entries, entries=entries,
links=links links=links,
) )
def create_next_paginated_link( def create_next_paginated_link(
request: Request, request: Request, total: int, current_count: int, offset: int, feed_title: str
total: int,
current_count: int,
offset: int,
feed_title: str
) -> Link | None: ) -> Link | None:
if total <= current_count + offset: if total <= current_count + offset:
return None return None
params = dict(request.query_params) params = dict(request.query_params)
params['currentPage'] = params.get('currentPage', 1) + 1 params["currentPage"] = params.get("currentPage", 1) + 1
next_url = f"{request.url.path}?{urlencode(list(params.items()), doseq=True)}" next_url = f"{request.url.path}?{urlencode(list(params.items()), doseq=True)}"
return Link( return Link(rel="next", href=next_url, title=feed_title, type=LinkTypes.NAVIGATION)
rel="next",
href=next_url,
title=feed_title,
type=LinkTypes.NAVIGATION
)
def create_search_link( def create_search_link(
request: Request, request: Request, exclude_params: set[str] | None = None
exclude_params: set[str] | None = None
) -> Link: ) -> Link:
"""Create search link with current filters applied""" """Create search link with current filters applied"""
if exclude_params is None: if exclude_params is None:
exclude_params = {'currentPage', 'feed_title', 'feed_id', 'search', 'paginated'} exclude_params = {"currentPage", "feed_title", "feed_id", "search", "paginated"}
params = { params = {k: v for k, v in request.query_params.items() if k not in exclude_params}
k: v for k, v in request.query_params.items()
if k not in exclude_params
}
return Link( return Link(
rel="search", rel="search",
@@ -277,7 +268,6 @@ def create_search_link(
) )
def create_pagination_links( def create_pagination_links(
request: Request, request: Request,
total: int, total: int,
@@ -296,14 +286,11 @@ def create_pagination_links(
params = dict(request.query_params) params = dict(request.query_params)
# Calculate next page number # Calculate next page number
current_page = (offset // limit) + 1 current_page = (offset // limit) + 1
params['currentPage'] = current_page + 1 params["currentPage"] = current_page + 1
next_url = f"{request.url.path}?{urlencode(params, doseq=True)}" next_url = f"{request.url.path}?{urlencode(params, doseq=True)}"
next_link = Link( next_link = Link(
rel="next", rel="next", href=next_url, title=f"{feed_title} - Next", type=link_type
href=next_url,
title=f"{feed_title} - Next",
type=link_type
) )
# Create previous link if not on first page # Create previous link if not on first page
@@ -311,14 +298,14 @@ def create_pagination_links(
params = dict(request.query_params) params = dict(request.query_params)
# Calculate previous page number # Calculate previous page number
current_page = (offset // limit) + 1 current_page = (offset // limit) + 1
params['currentPage'] = max(1, current_page - 1) params["currentPage"] = max(1, current_page - 1)
prev_url = f"{request.url.path}?{urlencode(params, doseq=True)}" prev_url = f"{request.url.path}?{urlencode(params, doseq=True)}"
prev_link = Link( prev_link = Link(
rel="previous", rel="previous",
href=prev_url, href=prev_url,
title=f"{feed_title} - Previous", title=f"{feed_title} - Previous",
type=link_type type=link_type,
) )
return PaginationResult(next_link, prev_link, offset, total) return PaginationResult(next_link, prev_link, offset, total)
+2
View File
@@ -213,6 +213,7 @@ async def create_directory(dir_path: Path | str) -> None:
await aios.makedirs(dir_path, exist_ok=True) await aios.makedirs(dir_path, exist_ok=True)
async def move_file(src_path: Path, dest_path: Path, create_dirs=True) -> None: async def move_file(src_path: Path, dest_path: Path, create_dirs=True) -> None:
""" """
Move a file from source to destination asynchronously. Move a file from source to destination asynchronously.
@@ -239,6 +240,7 @@ async def move_file(src_path: Path, dest_path: Path, create_dirs=True) -> None:
# all configured separately, so they can easily be separate mounts. # all configured separately, so they can easily be separate mounts.
shutil.move(str(src_path), str(dest_path)) shutil.move(str(src_path), str(dest_path))
async def copy_file(src_path: Path, dest_path: Path, create_dirs: bool = True) -> None: async def copy_file(src_path: Path, dest_path: Path, create_dirs: bool = True) -> None:
""" """
Copy a file, streaming it rather than reading it whole. Copy a file, streaming it rather than reading it whole.
+5
View File
@@ -8,9 +8,14 @@ bun.lockb
# Ignore artifacts: # Ignore artifacts:
build build
coverage coverage
.pytest_cache
# Miscellaneous # Miscellaneous
/static/ /static/
# Vendored third-party source, copied verbatim by scripts/vendor-foliate.sh # Vendored third-party source, copied verbatim by scripts/vendor-foliate.sh
/src/lib/vendor/ /src/lib/vendor/
# Generated by openapi-typescript, which has its own formatting. Reformatting it here would
# make every regeneration a several-thousand-line diff.
/src/lib/schema/openapi/schema.d.ts
+3 -1
View File
@@ -13,7 +13,9 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig( export default defineConfig(
includeIgnoreFile(gitignorePath), includeIgnoreFile(gitignorePath),
// Vendored third-party source. Tracked, so .gitignore does not cover it. // Vendored third-party source. Tracked, so .gitignore does not cover it.
{ ignores: ['src/lib/vendor/**'] }, // static/pdfjs is the pdf.js viewer, vendored the same way as src/lib/vendor/foliate-js;
// linting it produced 1717 of the 1804 errors this config used to report.
{ ignores: ['src/lib/vendor/**', 'static/pdfjs/**'] },
js.configs.recommended, js.configs.recommended,
...ts.configs.recommended, ...ts.configs.recommended,
...svelte.configs.recommended, ...svelte.configs.recommended,
+2 -1
View File
@@ -10,7 +10,8 @@
--radius: 0.625rem; --radius: 0.625rem;
/* Typography — system stacks, so nothing depends on a CDN or a webfont build. */ /* Typography — system stacks, so nothing depends on a CDN or a webfont build. */
--app-font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; --app-font-sans:
system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--app-font-serif: Georgia, 'Iowan Old Style', 'Times New Roman', serif; --app-font-serif: Georgia, 'Iowan Old Style', 'Times New Roman', serif;
--app-font-mono: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace; --app-font-mono: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace;
+21 -11
View File
@@ -1,5 +1,10 @@
import { command, getRequestEvent, query } from '$app/server'; import { command, getRequestEvent, query } from '$app/server';
import { bookshelfCreate, bookshelfQuerySchema, modifyBooksInShelf, type Bookshelf } from '$lib/schema/bookshelf'; import {
bookshelfCreate,
bookshelfQuerySchema,
modifyBooksInShelf,
type Bookshelf
} from '$lib/schema/bookshelf';
import { createQueryParams } from '$lib/utils'; import { createQueryParams } from '$lib/utils';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
@@ -19,7 +24,9 @@ export const listBookshelves = query(bookshelfQuerySchema, async (data) => {
return await response.json(); return await response.json();
}); });
export const addBooksToShelf = command(modifyBooksInShelf, async ({ shelf_id, ...data }): Promise<Bookshelf> => { export const addBooksToShelf = command(
modifyBooksInShelf,
async ({ shelf_id, ...data }): Promise<Bookshelf> => {
const { locals } = getRequestEvent(); const { locals } = getRequestEvent();
const params = createQueryParams(data); const params = createQueryParams(data);
@@ -31,10 +38,13 @@ export const addBooksToShelf = command(modifyBooksInShelf, async ({ shelf_id, ..
error(response.status, message); error(response.status, message);
} }
return await response.json() return await response.json();
}); }
);
export const removeBooksFromShelf = command(modifyBooksInShelf, async ({ shelf_id, ...data }): Promise<Bookshelf> => { export const removeBooksFromShelf = command(
modifyBooksInShelf,
async ({ shelf_id, ...data }): Promise<Bookshelf> => {
const { locals } = getRequestEvent(); const { locals } = getRequestEvent();
const params = createQueryParams(data); const params = createQueryParams(data);
@@ -46,19 +56,19 @@ export const removeBooksFromShelf = command(modifyBooksInShelf, async ({ shelf_i
error(response.status, message); error(response.status, message);
} }
return await response.json() return await response.json();
}); }
);
export const createBookshelf = command(bookshelfCreate, async (data) => { export const createBookshelf = command(bookshelfCreate, async (data) => {
const { locals } = getRequestEvent(); const { locals } = getRequestEvent();
const response = await locals.api.post(`/shelves`, data) const response = await locals.api.post(`/shelves`, data);
if (!response.ok) { if (!response.ok) {
const message = await response.text(); const message = await response.text();
error(response.status, message); error(response.status, message);
} }
return await response.json() return await response.json();
}) });
+2 -2
View File
@@ -11,7 +11,7 @@ export const listDevices = query(async (): Promise<Device[]> => {
if (!response.ok) error(500, 'An unkown error occurred'); if (!response.ok) error(500, 'An unkown error occurred');
const deviceResult = await response.json(); const deviceResult = await response.json();
return deviceResult.items return deviceResult.items;
}); });
export const createDevice = form(createDeviceSchema, async (data): Promise<Device> => { export const createDevice = form(createDeviceSchema, async (data): Promise<Device> => {
@@ -32,7 +32,7 @@ export const regenerateDeviceApiKey = command(z.string(), async (deviceId): Prom
if (!response.ok) error(500, 'An unknown error occurred'); if (!response.ok) error(500, 'An unknown error occurred');
return await response.json(); return await response.json();
}) });
export const deleteDevice = command(z.string(), async (deviceId): Promise<void> => { export const deleteDevice = command(z.string(), async (deviceId): Promise<void> => {
const { locals } = getRequestEvent(); const { locals } = getRequestEvent();
@@ -1,15 +1,16 @@
<script lang="ts"> <script lang="ts">
import * as Dialog from '$lib/components/ui/dialog/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Button } from "$lib/components/ui/button/index.js"; import { Button } from '$lib/components/ui/button/index.js';
import { Input } from "$lib/components/ui/input/index.js"; import { Input } from '$lib/components/ui/input/index.js';
import { Label } from "$lib/components/ui/label/index.js"; import { Label } from '$lib/components/ui/label/index.js';
let { open = $bindable(), onSubmit }: { open?: boolean, onSubmit: (name: string) => Promise<undefined> } = $props()
let shelfName = $state('')
let {
open = $bindable(),
onSubmit
}: { open?: boolean; onSubmit: (name: string) => Promise<undefined> } = $props();
let shelfName = $state('');
</script> </script>
<Dialog.Root bind:open> <Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[425px]"> <Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header> <Dialog.Header>
@@ -28,7 +28,9 @@
// directly in the markup keeps it static. // directly in the markup keeps it static.
const header = $derived({ const header = $derived({
title: 'chitai', title: 'chitai',
url: resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(libraryState.activeLibrary!.id) }) url: resolve('/(root)/(library)/library/[libraryId]', {
libraryId: String(libraryState.activeLibrary!.id)
})
}); });
</script> </script>
@@ -2,7 +2,7 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/index.js'; import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import { Badge } from "$lib/components/ui/badge/index.js"; import { Badge } from '$lib/components/ui/badge/index.js';
import { getLibraryState, LibraryState } from '$lib/state/library.svelte'; import { getLibraryState, LibraryState } from '$lib/state/library.svelte';
import { LIBRARY_ICONS } from '$lib/components/ui/icon-picker/index.js'; import { LIBRARY_ICONS } from '$lib/components/ui/icon-picker/index.js';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down'; import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
@@ -12,7 +12,9 @@
const libraryState = getLibraryState(); const libraryState = getLibraryState();
const sidebar = useSidebar(); const sidebar = useSidebar();
const activeIcon = $derived(LIBRARY_ICONS[libraryState.activeLibrary?.icon ?? 'library'] ?? LIBRARY_ICONS['library']); const activeIcon = $derived(
LIBRARY_ICONS[libraryState.activeLibrary?.icon ?? 'library'] ?? LIBRARY_ICONS['library']
);
</script> </script>
<Sidebar.Menu> <Sidebar.Menu>
@@ -35,7 +37,7 @@
<span class="ml-1 truncate font-semibold"> <span class="ml-1 truncate font-semibold">
{libraryState.activeLibrary!.name} {libraryState.activeLibrary!.name}
</span> </span>
<span class="ml-1 truncate font-mono text-xs tabular-nums text-muted-foreground"> <span class="ml-1 truncate font-mono text-xs text-muted-foreground tabular-nums">
{libraryState.activeLibrary!.total ?? 0} books {libraryState.activeLibrary!.total ?? 0} books
</span> </span>
</div> </div>
@@ -51,15 +53,15 @@
> >
<DropdownMenu.Label class="text-xs text-muted-foreground">Libraries</DropdownMenu.Label> <DropdownMenu.Label class="text-xs text-muted-foreground">Libraries</DropdownMenu.Label>
{#each libraryState.libraries as library (library.name)} {#each libraryState.libraries as library (library.name)}
{@const LibraryIcon = (LIBRARY_ICONS[library.icon ?? 'library'] ?? LIBRARY_ICONS['library']).component} {@const LibraryIcon = (
LIBRARY_ICONS[library.icon ?? 'library'] ?? LIBRARY_ICONS['library']
).component}
<DropdownMenu.Item onSelect={() => libraryState.setActive(library.id)} class="gap-2 p-2"> <DropdownMenu.Item onSelect={() => libraryState.setActive(library.id)} class="gap-2 p-2">
<div class="flex size-6 items-center justify-center rounded-md border"> <div class="flex size-6 items-center justify-center rounded-md border">
<LibraryIcon class="size-3.5 shrink-0" /> <LibraryIcon class="size-3.5 shrink-0" />
</div> </div>
{library.name} {library.name}
<Badge <Badge variant="outline" class="ml-auto font-semibold">
variant="outline"
class="font-semibold ml-auto">
{library.total ?? 0} {library.total ?? 0}
</Badge> </Badge>
</DropdownMenu.Item> </DropdownMenu.Item>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -14,10 +14,10 @@
bind:this={ref} bind:this={ref}
data-slot="avatar-badge" data-slot="avatar-badge"
class={cn( class={cn(
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none", 'absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none',
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden", 'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", 'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", 'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
className className
)} )}
{...restProps} {...restProps}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui"; import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +13,7 @@
bind:ref bind:ref
data-slot="avatar-fallback" data-slot="avatar-fallback"
class={cn( class={cn(
"rounded-full bg-muted text-muted-foreground flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs", 'flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs',
className className
)} )}
{...restProps} {...restProps}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -14,7 +14,7 @@
bind:this={ref} bind:this={ref}
data-slot="avatar-group-count" data-slot="avatar-group-count"
class={cn( class={cn(
"size-8 rounded-full bg-muted text-sm text-muted-foreground group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 relative flex shrink-0 items-center justify-center ring-2 ring-background", 'relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3',
className className
)} )}
{...restProps} {...restProps}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -14,7 +14,7 @@
bind:this={ref} bind:this={ref}
data-slot="avatar-group" data-slot="avatar-group"
class={cn( class={cn(
"cn-avatar-group group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background", 'cn-avatar-group group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background',
className className
)} )}
{...restProps} {...restProps}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui"; import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -12,6 +12,6 @@
<AvatarPrimitive.Image <AvatarPrimitive.Image
bind:ref bind:ref
data-slot="avatar-image" data-slot="avatar-image"
class={cn("rounded-full aspect-square size-full object-cover", className)} class={cn('aspect-square size-full rounded-full object-cover', className)}
{...restProps} {...restProps}
/> />
@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui"; import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
loadingStatus = $bindable("loading"), loadingStatus = $bindable('loading'),
size = "default", size = 'default',
class: className, class: className,
...restProps ...restProps
}: AvatarPrimitive.RootProps & { }: AvatarPrimitive.RootProps & {
size?: "default" | "sm" | "lg"; size?: 'default' | 'sm' | 'lg';
} = $props(); } = $props();
</script> </script>
@@ -19,7 +19,7 @@
data-slot="avatar" data-slot="avatar"
data-size={size} data-size={size}
class={cn( class={cn(
"size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:border-border after:mix-blend-darken dark:after:mix-blend-lighten", 'group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten',
className className
)} )}
{...restProps} {...restProps}
@@ -1,9 +1,9 @@
import Badge from "./avatar-badge.svelte"; import Badge from './avatar-badge.svelte';
import Fallback from "./avatar-fallback.svelte"; import Fallback from './avatar-fallback.svelte';
import GroupCount from "./avatar-group-count.svelte"; import GroupCount from './avatar-group-count.svelte';
import Group from "./avatar-group.svelte"; import Group from './avatar-group.svelte';
import Image from "./avatar-image.svelte"; import Image from './avatar-image.svelte';
import Root from "./avatar.svelte"; import Root from './avatar.svelte';
export { export {
Root, Root,
@@ -18,5 +18,5 @@ export {
Fallback as AvatarFallback, Fallback as AvatarFallback,
Badge as AvatarBadge, Badge as AvatarBadge,
Group as AvatarGroup, Group as AvatarGroup,
GroupCount as AvatarGroupCount, GroupCount as AvatarGroupCount
}; };
@@ -15,8 +15,7 @@
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline', link: 'text-primary underline-offset-4 hover:underline',
accent: accent: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90'
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90'
}, },
size: { size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3', default: 'h-9 px-4 py-2 has-[>svg]:px-3',
@@ -45,11 +45,7 @@
{/snippet} {/snippet}
</Popover.Trigger> </Popover.Trigger>
<Popover.Content class="w-64 p-3" align="start"> <Popover.Content class="w-64 p-3" align="start">
<Input <Input bind:value={search} placeholder="Search icons..." class="mb-3 h-8" />
bind:value={search}
placeholder="Search icons..."
class="mb-3 h-8"
/>
<ScrollArea class="h-48"> <ScrollArea class="h-48">
<div class="grid grid-cols-6 gap-1"> <div class="grid grid-cols-6 gap-1">
{#each filteredIcons as [key, icon] (key)} {#each filteredIcons as [key, icon] (key)}
@@ -58,7 +54,10 @@
<Tooltip.Trigger> <Tooltip.Trigger>
<button <button
type="button" type="button"
class="flex size-8 items-center justify-center rounded-md hover:bg-accent {value === key ? 'bg-accent' : ''}" class="flex size-8 items-center justify-center rounded-md hover:bg-accent {value ===
key
? 'bg-accent'
: ''}"
onclick={() => selectIcon(key)} onclick={() => selectIcon(key)}
> >
<IconComponent class="size-4" /> <IconComponent class="size-4" />
@@ -59,7 +59,10 @@ import type { Component } from 'svelte';
export type IconName = keyof typeof LIBRARY_ICONS; export type IconName = keyof typeof LIBRARY_ICONS;
export const LIBRARY_ICONS: Record<string, { component: Component; label: string; category: string }> = { export const LIBRARY_ICONS: Record<
string,
{ component: Component; label: string; category: string }
> = {
// Generic // Generic
library: { component: Library, label: 'Library', category: 'Generic' }, library: { component: Library, label: 'Library', category: 'Generic' },
'book-open': { component: BookOpen, label: 'Book Open', category: 'Generic' }, 'book-open': { component: BookOpen, label: 'Book Open', category: 'Generic' },
@@ -1,8 +1,8 @@
import Root from "./popover.svelte"; import Root from './popover.svelte';
import Close from "./popover-close.svelte"; import Close from './popover-close.svelte';
import Content from "./popover-content.svelte"; import Content from './popover-content.svelte';
import Trigger from "./popover-trigger.svelte"; import Trigger from './popover-trigger.svelte';
import Portal from "./popover-portal.svelte"; import Portal from './popover-portal.svelte';
export { export {
Root, Root,
@@ -15,5 +15,5 @@ export {
Content as PopoverContent, Content as PopoverContent,
Trigger as PopoverTrigger, Trigger as PopoverTrigger,
Close as PopoverClose, Close as PopoverClose,
Portal as PopoverPortal, Portal as PopoverPortal
}; };
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui"; import { Popover as PopoverPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props(); let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
</script> </script>
@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui"; import { Popover as PopoverPrimitive } from 'bits-ui';
import PopoverPortal from "./popover-portal.svelte"; import PopoverPortal from './popover-portal.svelte';
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js"; import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import type { ComponentProps } from "svelte"; import type { ComponentProps } from 'svelte';
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
sideOffset = 4, sideOffset = 4,
align = "center", align = 'center',
portalProps, portalProps,
...restProps ...restProps
}: PopoverPrimitive.ContentProps & { }: PopoverPrimitive.ContentProps & {
@@ -23,7 +23,7 @@
{sideOffset} {sideOffset}
{align} {align}
class={cn( class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden", 'z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className className
)} )}
{...restProps} {...restProps}
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui"; import { Popover as PopoverPrimitive } from 'bits-ui';
let { ...restProps }: PopoverPrimitive.PortalProps = $props(); let { ...restProps }: PopoverPrimitive.PortalProps = $props();
</script> </script>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
import { Popover as PopoverPrimitive } from "bits-ui"; import { Popover as PopoverPrimitive } from 'bits-ui';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -12,6 +12,6 @@
<PopoverPrimitive.Trigger <PopoverPrimitive.Trigger
bind:ref bind:ref
data-slot="popover-trigger" data-slot="popover-trigger"
class={cn("", className)} class={cn('', className)}
{...restProps} {...restProps}
/> />
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui"; import { Popover as PopoverPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props(); let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
</script> </script>
@@ -1,6 +1,6 @@
import { Tooltip as TooltipPrimitive } from "bits-ui"; import { Tooltip as TooltipPrimitive } from 'bits-ui';
import Trigger from "./tooltip-trigger.svelte"; import Trigger from './tooltip-trigger.svelte';
import Content from "./tooltip-content.svelte"; import Content from './tooltip-content.svelte';
const Root = TooltipPrimitive.Root; const Root = TooltipPrimitive.Root;
const Provider = TooltipPrimitive.Provider; const Provider = TooltipPrimitive.Provider;
@@ -17,5 +17,5 @@ export {
Content as TooltipContent, Content as TooltipContent,
Trigger as TooltipTrigger, Trigger as TooltipTrigger,
Provider as TooltipProvider, Provider as TooltipProvider,
Portal as TooltipPortal, Portal as TooltipPortal
}; };
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui"; import { Tooltip as TooltipPrimitive } from 'bits-ui';
import { cn } from "$lib/utils.js"; import { cn } from '$lib/utils.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
sideOffset = 0, sideOffset = 0,
side = "top", side = 'top',
children, children,
arrowClasses, arrowClasses,
...restProps ...restProps
@@ -22,7 +22,7 @@
{sideOffset} {sideOffset}
{side} {side}
class={cn( class={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-tooltip-content-transform-origin) z-50 w-fit text-balance rounded-md px-3 py-1.5 text-xs", 'z-50 w-fit origin-(--bits-tooltip-content-transform-origin) animate-in rounded-md bg-primary px-3 py-1.5 text-xs text-balance text-primary-foreground fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
className className
)} )}
{...restProps} {...restProps}
@@ -32,11 +32,11 @@
{#snippet child({ props })} {#snippet child({ props })}
<div <div
class={cn( class={cn(
"bg-primary z-50 size-2.5 rotate-45 rounded-[2px]", 'z-50 size-2.5 rotate-45 rounded-[2px] bg-primary',
"data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]", 'data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]',
"data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]", 'data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]',
"data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2", 'data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2',
"data-[side=left]:-translate-y-[calc(50%_-_3px)]", 'data-[side=left]:-translate-y-[calc(50%_-_3px)]',
arrowClasses arrowClasses
)} )}
{...props} {...props}
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui"; import { Tooltip as TooltipPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: TooltipPrimitive.TriggerProps = $props(); let { ref = $bindable(null), ...restProps }: TooltipPrimitive.TriggerProps = $props();
</script> </script>
@@ -49,7 +49,8 @@
Read Read
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
onclick={() => bookOps.downloadBookFile(book.id, book.files[0].id, book.files[0].filename)} onclick={() =>
bookOps.downloadBookFile(book.id, book.files[0].id, book.files[0].filename)}
> >
<Download class="size-4" /> <Download class="size-4" />
Download Download
@@ -111,7 +111,11 @@
<Spinner /> <Spinner />
</div> </div>
{:else if bookCollection.books.length > 0} {:else if bookCollection.books.length > 0}
<ScrollArea bind:viewportRef={scrollContainer} orientation="both" class="h-[calc(100vh-11rem)] w-full px-5 pb-5"> <ScrollArea
bind:viewportRef={scrollContainer}
orientation="both"
class="h-[calc(100vh-11rem)] w-full px-5 pb-5"
>
{#if bookCollection.view === 'grid'} {#if bookCollection.view === 'grid'}
<BookGrid books={bookCollection.books} /> <BookGrid books={bookCollection.books} />
{:else if bookCollection.view === 'list'} {:else if bookCollection.view === 'list'}
@@ -170,7 +174,7 @@
</Sidebar.Inset> </Sidebar.Inset>
<!-- Filter Sidebar (right-side) --> <!-- Filter Sidebar (right-side) -->
<FilterSidebar class="m-2 pb-5 h-full pt-20" /> <FilterSidebar class="m-2 h-full pt-20 pb-5" />
</Sidebar.Provider> </Sidebar.Provider>
</div> </div>
</div> </div>
@@ -73,8 +73,8 @@
bookOps.deleteDialogTitle = `Delete "${book.title}"?`; bookOps.deleteDialogTitle = `Delete "${book.title}"?`;
bookOps.deleteFn = async (deleteFiles: boolean) => { bookOps.deleteFn = async (deleteFiles: boolean) => {
await bookOps.deleteBooks([book.id], deleteFiles); await bookOps.deleteBooks([book.id], deleteFiles);
libraryState.activeLibrary!.total!-- libraryState.activeLibrary!.total!--;
bookshelfState.deletedBooks([book]) bookshelfState.deletedBooks([book]);
}; };
bookOps.deleteDialogOpen = true; bookOps.deleteDialogOpen = true;
}} }}
@@ -121,7 +121,10 @@
? 'cursor-pointer' ? 'cursor-pointer'
: ''}" : ''}"
> >
<a href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })} class="shrink-0"> <a
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
class="shrink-0"
>
<BookCover {book} height={110} /> <BookCover {book} height={110} />
</a> </a>
@@ -18,7 +18,7 @@
aria-pressed={active} aria-pressed={active}
onclick={() => (active ? bookCollection.clearView() : bookCollection.applyPreset(preset))} onclick={() => (active ? bookCollection.clearView() : bookCollection.applyPreset(preset))}
class="shrink-0 rounded-full border px-3 py-1 text-xs whitespace-nowrap transition-colors {active class="shrink-0 rounded-full border px-3 py-1 text-xs whitespace-nowrap transition-colors {active
? 'border-primary bg-primary text-primary-foreground font-medium' ? 'border-primary bg-primary font-medium text-primary-foreground'
: 'border-border text-muted-foreground hover:border-muted-foreground hover:text-foreground'}" : 'border-border text-muted-foreground hover:border-muted-foreground hover:text-foreground'}"
> >
{preset.label} {preset.label}
@@ -19,9 +19,7 @@
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} relative" class="{buttonVariants({ variant: 'ghost', size: 'icon' })} relative"
> >
{#if bookCollection.hasActiveSort} {#if bookCollection.hasActiveSort}
<div <div class="absolute top-[5px] right-[5px] h-1.5 w-1.5 rounded-full bg-flag"></div>
class="absolute top-[5px] right-[5px] h-1.5 w-1.5 rounded-full bg-flag"
></div>
{/if} {/if}
<ArrowUpDown /> <ArrowUpDown />
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
+1 -1
View File
@@ -19,7 +19,7 @@ export const bookshelfCreate = z.object({
title: z.string().min(1, 'Must have a title'), title: z.string().min(1, 'Must have a title'),
library_id: stringCoerce.optional(), library_id: stringCoerce.optional(),
book_ids: stringArrayCoerce.optional() book_ids: stringArrayCoerce.optional()
}) });
export type BookshelfQuerySchema = typeof bookshelfQuerySchema; export type BookshelfQuerySchema = typeof bookshelfQuerySchema;
export type ModifyBooksInShelf = typeof modifyBooksInShelf; export type ModifyBooksInShelf = typeof modifyBooksInShelf;
+2 -2
View File
@@ -1,8 +1,8 @@
import { z } from 'zod'; import { z } from 'zod';
import type { components } from './openapi/schema'; import type { components } from './openapi/schema';
export type Device = components['schemas']['KosyncDeviceRead'] export type Device = components['schemas']['KosyncDeviceRead'];
export const createDeviceSchema = z.object({ export const createDeviceSchema = z.object({
name: z.string().min(1, 'Name cannot be empty') name: z.string().min(1, 'Name cannot be empty')
}) });
@@ -1,16 +1,11 @@
import { invalidate } from '$app/navigation'; import { invalidate } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { import { deleteBookFiles, deleteBooks, listBooks, updateBookProgress } from '$lib/api';
deleteBookFiles,
deleteBooks,
listBooks,
updateBookProgress
} from '$lib/api';
import { import {
type Book, type Book,
type UpdateBookProgress, type UpdateBookProgress,
type BookQuery, type BookQuery,
type PaginatedResponse, type PaginatedResponse
} from '$lib/schema'; } from '$lib/schema';
import { getContext, setContext } from 'svelte'; import { getContext, setContext } from 'svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
+7 -11
View File
@@ -7,12 +7,9 @@ export class BookSelectionState {
readonly selectionModeActive = $derived(this.selectedBooks.size !== 0); readonly selectionModeActive = $derived(this.selectedBooks.size !== 0);
toggleSelection(book: Book) { toggleSelection(book: Book) {
const bookId = book.id.toString() const bookId = book.id.toString();
if (this.selectedBooks.has(bookId)) if (this.selectedBooks.has(bookId)) this.selectedBooks.delete(bookId);
this.selectedBooks.delete(bookId); else this.selectedBooks.set(bookId, book);
else
this.selectedBooks.set(bookId, book);
} }
isSelected(id: number | string): boolean { isSelected(id: number | string): boolean {
@@ -20,11 +17,11 @@ export class BookSelectionState {
} }
getSelectedBooks(): Book[] { getSelectedBooks(): Book[] {
return Array.from(this.selectedBooks.values()) return Array.from(this.selectedBooks.values());
} }
getSelectedIds(): string[] { getSelectedIds(): string[] {
return Array.from(this.selectedBooks.keys()) return Array.from(this.selectedBooks.keys());
} }
numSelected() { numSelected() {
@@ -33,9 +30,8 @@ export class BookSelectionState {
selectAll(books: Book[]) { selectAll(books: Book[]) {
books.forEach((book) => { books.forEach((book) => {
const bookId = book.id.toString() const bookId = book.id.toString();
if (!this.selectedBooks.has(bookId)) if (!this.selectedBooks.has(bookId)) this.selectedBooks.set(bookId, book);
this.selectedBooks.set(bookId, book)
}); });
} }
+27 -26
View File
@@ -12,7 +12,7 @@ export class BookshelfState {
const id = libraryId.toString(); const id = libraryId.toString();
if (!this.libraryBookshelves.has(id)) { if (!this.libraryBookshelves.has(id)) {
this.fetchBookshelves(id).then(shelves => { this.fetchBookshelves(id).then((shelves) => {
this.libraryBookshelves.set(id, shelves); this.libraryBookshelves.set(id, shelves);
}); });
} }
@@ -38,25 +38,25 @@ export class BookshelfState {
title: name, title: name,
library_id: libraryId, library_id: libraryId,
book_ids: booksToAdd book_ids: booksToAdd
}) });
if (bookshelf.library_id) { if (bookshelf.library_id) {
const currentShelves = this.libraryBookshelves.get(bookshelf.library_id.toString()) || []; const currentShelves = this.libraryBookshelves.get(bookshelf.library_id.toString()) || [];
this.libraryBookshelves.set(bookshelf.library_id.toString(), [...currentShelves, bookshelf]); this.libraryBookshelves.set(bookshelf.library_id.toString(), [
...currentShelves,
bookshelf
]);
} }
invalidate('app:books') invalidate('app:books');
if (booksToAdd?.length) if (booksToAdd?.length) toast.success(`Added ${booksToAdd.length} books to '${name}'`);
toast.success(`Added ${booksToAdd.length} books to '${name}'`) else toast.success(`Created shelf '${name}'`);
else
toast.success(`Created shelf '${name}'`)
return bookshelf
return bookshelf;
} catch (error) { } catch (error) {
toast.error(`Failed to create bookshelf '${name}'`) toast.error(`Failed to create bookshelf '${name}'`);
console.error(`Failed to create bookshelf: `, error) console.error(`Failed to create bookshelf: `, error);
} }
} }
@@ -67,7 +67,7 @@ export class BookshelfState {
book_ids: bookIds book_ids: bookIds
}); });
this.updateShelf(shelf) this.updateShelf(shelf);
if (bookIds.length === 1) toast.success(`Added book to shelf!`); if (bookIds.length === 1) toast.success(`Added book to shelf!`);
else toast.success(`Added ${bookIds.length} books to shelf!`); else toast.success(`Added ${bookIds.length} books to shelf!`);
@@ -84,7 +84,7 @@ export class BookshelfState {
book_ids: bookIds book_ids: bookIds
}); });
this.updateShelf(shelf) this.updateShelf(shelf);
if (bookIds.length === 1) toast.success(`Removed book from shelf.`); if (bookIds.length === 1) toast.success(`Removed book from shelf.`);
else toast.success(`Removed ${bookIds.length} books from shelf!`); else toast.success(`Removed ${bookIds.length} books from shelf!`);
@@ -96,38 +96,39 @@ export class BookshelfState {
async updateShelf(shelf: Bookshelf) { async updateShelf(shelf: Bookshelf) {
if (!shelf.library_id) return; if (!shelf.library_id) return;
const bookshelves = this.libraryBookshelves.get(shelf.library_id.toString()) const bookshelves = this.libraryBookshelves.get(shelf.library_id.toString());
if (!bookshelves) return; if (!bookshelves) return;
const index = bookshelves.findIndex(bookshelf => bookshelf.id === shelf.id) const index = bookshelves.findIndex((bookshelf) => bookshelf.id === shelf.id);
if (index !== -1) { if (index !== -1) {
this.libraryBookshelves.set( this.libraryBookshelves.set(shelf.library_id.toString(), [
shelf.library_id.toString(), ...bookshelves.slice(0, index),
[...bookshelves.slice(0, index), shelf, ...bookshelves.slice(index + 1)] shelf,
); ...bookshelves.slice(index + 1)
]);
} }
} }
deletedBooks(books: Book[]) { deletedBooks(books: Book[]) {
// Assume all books are in the same library // Assume all books are in the same library
const libraryId = books[0].library_id.toString() const libraryId = books[0].library_id.toString();
const bookshelves = this.libraryBookshelves.get(libraryId); const bookshelves = this.libraryBookshelves.get(libraryId);
if (!bookshelves) return; if (!bookshelves) return;
// Create a Set of shelf IDs that need updating for efficient lookup // Create a Set of shelf IDs that need updating for efficient lookup
const shelfIdsToUpdate = new Set<number>(); const shelfIdsToUpdate = new Set<number>();
books.forEach(book => { books.forEach((book) => {
book.lists.forEach(shelf => { book.lists.forEach((shelf) => {
shelfIdsToUpdate.add(shelf.id); shelfIdsToUpdate.add(shelf.id);
}); });
}); });
const updatedBookshelves = bookshelves.map(shelf => { const updatedBookshelves = bookshelves.map((shelf) => {
if (shelfIdsToUpdate.has(shelf.id)) { if (shelfIdsToUpdate.has(shelf.id)) {
// Count how many times this shelf appears across all deleted books // Count how many times this shelf appears across all deleted books
let decrementBy = 0; let decrementBy = 0;
books.forEach(book => { books.forEach((book) => {
if (book.lists.some(s => s.id === shelf.id)) { if (book.lists.some((s) => s.id === shelf.id)) {
decrementBy++; decrementBy++;
} }
}); });
+5 -2
View File
@@ -32,9 +32,12 @@ export class LibraryState {
this.activeLibrary = this.libraries.find((lib) => lib.id === libraryId) || this.libraries[0]; this.activeLibrary = this.libraries.find((lib) => lib.id === libraryId) || this.libraries[0];
if (browser) { if (browser) {
localStorage.setItem('previousLibraryId', this.activeLibrary.id.toString()); localStorage.setItem('previousLibraryId', this.activeLibrary.id.toString());
await goto(resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(libraryId) }), { await goto(
resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(libraryId) }),
{
invalidate: ['app:libraries'] invalidate: ['app:libraries']
}); }
);
} }
} }
+8 -2
View File
@@ -44,7 +44,10 @@ export const FONT_STACKS: { label: string; value: string }[] = [
{ label: 'Palatino', value: "'Palatino Linotype', Palatino, 'Book Antiqua', serif" }, { label: 'Palatino', value: "'Palatino Linotype', Palatino, 'Book Antiqua', serif" },
{ label: 'Helvetica', value: "'Helvetica Neue', Helvetica, Arial, sans-serif" }, { label: 'Helvetica', value: "'Helvetica Neue', Helvetica, Arial, sans-serif" },
{ label: 'Verdana', value: 'Verdana, Geneva, sans-serif' }, { label: 'Verdana', value: 'Verdana, Geneva, sans-serif' },
{ label: 'Monospace', value: "ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace" } {
label: 'Monospace',
value: "ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace"
}
]; ];
export type Palette = Record<string, string>; export type Palette = Record<string, string>;
@@ -299,7 +302,10 @@ export function parseThemeCookie(raw: string | undefined | null): ThemeConfig {
try { try {
const parsed = JSON.parse(decodeURIComponent(raw)); const parsed = JSON.parse(decodeURIComponent(raw));
if (!parsed || typeof parsed !== 'object') return { preset: DEFAULT_PRESET_ID }; if (!parsed || typeof parsed !== 'object') return { preset: DEFAULT_PRESET_ID };
return { ...parsed, preset: typeof parsed.preset === 'string' ? parsed.preset : DEFAULT_PRESET_ID }; return {
...parsed,
preset: typeof parsed.preset === 'string' ? parsed.preset : DEFAULT_PRESET_ID
};
} catch { } catch {
return { preset: DEFAULT_PRESET_ID }; return { preset: DEFAULT_PRESET_ID };
} }
@@ -1,15 +1,14 @@
<script lang="ts"> <script lang="ts">
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js'; import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import * as Empty from '$lib/components/ui/empty/index.js' import * as Empty from '$lib/components/ui/empty/index.js';
import { Button } from '$lib/components/ui/button/index.js' import { Button } from '$lib/components/ui/button/index.js';
import BookList from '$lib/components/view/book-list.svelte'; import BookList from '$lib/components/view/book-list.svelte';
import { FolderX, Upload } from '@lucide/svelte'; import { FolderX, Upload } from '@lucide/svelte';
import { getBookOperationsState } from '$lib/state/bookOperations.svelte.js'; import { getBookOperationsState } from '$lib/state/bookOperations.svelte.js';
let { data } = $props(); let { data } = $props();
let bookOps = getBookOperationsState() let bookOps = getBookOperationsState();
</script> </script>
<ScrollArea class="h-full w-full"> <ScrollArea class="h-full w-full">
@@ -22,7 +21,7 @@
<!-- Empty when no preset returned anything, i.e. the library has no books --> <!-- Empty when no preset returned anything, i.e. the library has no books -->
{#if data.presets.every((preset) => data.shelves[preset.id].length === 0)} {#if data.presets.every((preset) => data.shelves[preset.id].length === 0)}
<div class="flex flex-col items-center justify-center w-full h-full"> <div class="flex h-full w-full flex-col items-center justify-center">
<!-- Show a CTA to upload books if the library is empty --> <!-- Show a CTA to upload books if the library is empty -->
<Empty.Root class="mb-[12vh]"> <Empty.Root class="mb-[12vh]">
<Empty.Header> <Empty.Header>
@@ -44,7 +44,6 @@
}); });
</script> </script>
<div class="[--header-height:calc(--spacing(14))]"> <div class="[--header-height:calc(--spacing(14))]">
<Sidebar.Provider class="flex flex-col"> <Sidebar.Provider class="flex flex-col">
<div class="flex h-screen"> <div class="flex h-screen">
@@ -71,7 +71,8 @@
<!-- Colours --> <!-- Colours -->
<section class="flex flex-col gap-3"> <section class="flex flex-col gap-3">
<Label class="text-xs font-medium tracking-wider text-muted-foreground uppercase">Colours</Label> <Label class="text-xs font-medium tracking-wider text-muted-foreground uppercase">Colours</Label
>
<div class="grid gap-x-6 gap-y-1 sm:grid-cols-2"> <div class="grid gap-x-6 gap-y-1 sm:grid-cols-2">
{#each COLOR_TOKENS as token (token.key)} {#each COLOR_TOKENS as token (token.key)}
<div class="flex items-center gap-3 rounded-md px-2 py-2 hover:bg-muted"> <div class="flex items-center gap-3 rounded-md px-2 py-2 hover:bg-muted">
@@ -94,7 +95,8 @@
<!-- Radius --> <!-- Radius -->
<section class="flex flex-col gap-3"> <section class="flex flex-col gap-3">
<Label class="text-xs font-medium tracking-wider text-muted-foreground uppercase">Corners</Label> <Label class="text-xs font-medium tracking-wider text-muted-foreground uppercase">Corners</Label
>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each RADII as option (option.value)} {#each RADII as option (option.value)}
<button <button
@@ -138,7 +140,8 @@
<!-- Preview --> <!-- Preview -->
<section class="flex flex-col gap-3"> <section class="flex flex-col gap-3">
<Label class="text-xs font-medium tracking-wider text-muted-foreground uppercase">Preview</Label> <Label class="text-xs font-medium tracking-wider text-muted-foreground uppercase">Preview</Label
>
<div class="rounded-lg border bg-sidebar p-4"> <div class="rounded-lg border bg-sidebar p-4">
<div class="flex flex-col gap-4 rounded-lg border bg-card p-4"> <div class="flex flex-col gap-4 rounded-lg border bg-card p-4">
<div class="flex items-baseline justify-between gap-4"> <div class="flex items-baseline justify-between gap-4">
@@ -1,4 +1,4 @@
import { listDevices } from "$lib/api/device.remote"; import { listDevices } from '$lib/api/device.remote';
export async function load() { export async function load() {
const devices = await listDevices(); const devices = await listDevices();
@@ -109,9 +109,7 @@
<Smartphone /> <Smartphone />
</Empty.Media> </Empty.Media>
<Empty.Title>No devices</Empty.Title> <Empty.Title>No devices</Empty.Title>
<Empty.Description> <Empty.Description>Add a device to sync your KOReader reading progress.</Empty.Description>
Add a device to sync your KOReader reading progress.
</Empty.Description>
</Empty.Header> </Empty.Header>
<Empty.Content> <Empty.Content>
<Button onclick={() => (createDialogOpen = true)}> <Button onclick={() => (createDialogOpen = true)}>
@@ -224,12 +222,14 @@
<AlertDialog.Header> <AlertDialog.Header>
<AlertDialog.Title>Regenerate API key?</AlertDialog.Title> <AlertDialog.Title>Regenerate API key?</AlertDialog.Title>
<AlertDialog.Description> <AlertDialog.Description>
This will invalidate the current API key for "{regenerateConfirmDevice?.name}". This will invalidate the current API key for "{regenerateConfirmDevice?.name}". You will
You will need to update the key in your KOReader device settings. need to update the key in your KOReader device settings.
</AlertDialog.Description> </AlertDialog.Description>
</AlertDialog.Header> </AlertDialog.Header>
<AlertDialog.Footer> <AlertDialog.Footer>
<AlertDialog.Cancel onclick={() => (regenerateConfirmDevice = null)}>Cancel</AlertDialog.Cancel> <AlertDialog.Cancel onclick={() => (regenerateConfirmDevice = null)}
>Cancel</AlertDialog.Cancel
>
<AlertDialog.Action <AlertDialog.Action
onclick={() => regenerateConfirmDevice && handleRegenerate(regenerateConfirmDevice)} onclick={() => regenerateConfirmDevice && handleRegenerate(regenerateConfirmDevice)}
> >
@@ -38,7 +38,9 @@
> >
<Table.Cell class="font-medium"> <Table.Cell class="font-medium">
<a <a
href={resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(library.id) })} href={resolve('/(root)/(library)/library/[libraryId]', {
libraryId: String(library.id)
})}
class="hover:underline">{library.name}</a class="hover:underline">{library.name}</a
> >
</Table.Cell> </Table.Cell>
@@ -10,9 +10,7 @@ export async function load({ params, depends }) {
// identifiers, description, publisher — so fetch them in one go rather than per // identifiers, description, publisher — so fetch them in one go rather than per
// card, and let the dialog pick out the books for its own group. // card, and let the dialog pick out the books for its own group.
const ids = [...new Set(groups.flatMap((group) => group.books.map((book) => book.book_id)))]; const ids = [...new Set(groups.flatMap((group) => group.books.map((book) => book.book_id)))];
const books = ids.length const books = ids.length ? await listBooks({ ids, pageSize: ids.length }) : { items: [] };
? await listBooks({ ids, pageSize: ids.length })
: { items: [] };
return { groups, books: books.items }; return { groups, books: books.items };
} }