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:
@@ -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"]
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
# src/chitai/controllers/access.py
|
||||
|
||||
# Standard library
|
||||
from typing import Annotated, Any
|
||||
from typing import Annotated
|
||||
import logging
|
||||
|
||||
# Third-party libraries
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -273,5 +273,3 @@ class BookProgressCreate(BaseModel):
|
||||
completed: bool | None = None
|
||||
device_type: str | None = None
|
||||
device_id: str | None = None
|
||||
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class ShelfRead(BaseModel):
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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,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 (
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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']
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1
-3
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user