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