Compare commits

..

4 Commits

18 changed files with 758 additions and 105 deletions
Vendored
+1
View File
@@ -1,4 +1,5 @@
.env
library_service/static/books/
*.log
# Byte-compiled / optimized / DLL files
+1
View File
@@ -184,6 +184,7 @@ if __name__ == "__main__":
"library_service.main:app",
host="0.0.0.0",
port=8000,
proxy_headers=True,
forwarded_allow_ips="*",
log_config=LOGGING_CONFIG,
access_log=False,
+3 -1
View File
@@ -1,6 +1,7 @@
"""Модуль DB-моделей книг"""
from typing import TYPE_CHECKING, List
from uuid import UUID
from pgvector.sqlalchemy import Vector
from sqlalchemy import Column, String
@@ -26,7 +27,8 @@ class Book(BookBase, table=True):
sa_column=Column(String, nullable=False, default="active"),
description="Статус",
)
embedding: list[float] | None = Field(sa_column=Column(Vector(1024)))
embedding: list[float] | None = Field(sa_column=Column(Vector(1024)), description="Эмбэдинг для векторного поиска")
preview_id: UUID | None = Field(default=None, unique=True, index=True, description="UUID файла изображения")
authors: List["Author"] = Relationship(
back_populates="books", link_model=AuthorBookLink
)
+1
View File
@@ -46,6 +46,7 @@ class BookRead(BookBase):
id: int = Field(description="Идентификатор")
status: BookStatus = Field(description="Статус")
preview_urls: dict[str, str] = Field(default_factory=dict, description="URL изображений")
class BookList(SQLModel):
+3
View File
@@ -39,6 +39,7 @@ class BookWithAuthors(SQLModel):
description: str = Field(description="Описание")
page_count: int = Field(description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус")
preview_urls: dict[str, str] = Field(default_factory=dict, description="URL изображений")
authors: List[AuthorRead] = Field(
default_factory=list, description="Список авторов"
)
@@ -52,6 +53,7 @@ class BookWithGenres(SQLModel):
description: str = Field(description="Описание")
page_count: int = Field(description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус")
preview_urls: dict[str, str] | None = Field(default=None, description="URL изображений")
genres: List[GenreRead] = Field(default_factory=list, description="Список жанров")
@@ -63,6 +65,7 @@ class BookWithAuthorsAndGenres(SQLModel):
description: str = Field(description="Описание")
page_count: int = Field(description="Количество страниц")
status: BookStatus | None = Field(None, description="Статус")
preview_urls: dict[str, str] | None = Field(default=None, description="URL изображений")
authors: List[AuthorRead] = Field(
default_factory=list, description="Список авторов"
)
+125 -22
View File
@@ -1,4 +1,7 @@
"""Модуль работы с книгами"""
from library_service.services import transcode_image
import shutil
from uuid import uuid4
from pydantic import Field
from typing_extensions import Annotated
@@ -10,12 +13,12 @@ from sqlalchemy import text, case, distinct
from datetime import datetime, timezone
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status, UploadFile, File
from ollama import Client
from sqlmodel import Session, select, col, func
from library_service.auth import RequireStaff
from library_service.settings import get_session, OLLAMA_URL
from library_service.settings import get_session, OLLAMA_URL, BOOKS_PREVIEW_DIR
from library_service.models.enums import BookStatus
from library_service.models.db import (
Author,
@@ -47,9 +50,9 @@ def close_active_loan(session: Session, book_id: int) -> None:
"""Закрывает активную выдачу книги при изменении статуса"""
active_loan = session.exec(
select(BookUserLink)
.where(BookUserLink.book_id == book_id)
.where(BookUserLink.returned_at == None) # noqa: E711
).first()
.where(BookUserLink.book_id == book_id) # ty: ignore
.where(BookUserLink.returned_at == None) # ty: ignore
).first() # ty: ignore
if active_loan:
active_loan.returned_at = datetime.now(timezone.utc)
@@ -72,19 +75,19 @@ def filter_books(
size: int = Query(20, gt=0, le=100),
):
statement = select(Book).options(
selectinload(Book.authors), selectinload(Book.genres), defer(Book.embedding)
selectinload(Book.authors), selectinload(Book.genres), defer(Book.embedding) # ty: ignore
)
if min_page_count:
statement = statement.where(Book.page_count >= min_page_count)
statement = statement.where(Book.page_count >= min_page_count) # ty: ignore
if max_page_count:
statement = statement.where(Book.page_count <= max_page_count)
statement = statement.where(Book.page_count <= max_page_count) # ty: ignore
if author_ids:
statement = statement.where(
exists().where(
AuthorBookLink.book_id == Book.id,
AuthorBookLink.author_id.in_(author_ids),
AuthorBookLink.book_id == Book.id, # ty: ignore
AuthorBookLink.author_id.in_(author_ids), # ty: ignore
)
)
@@ -92,7 +95,7 @@ def filter_books(
for genre_id in genre_ids:
statement = statement.where(
exists().where(
GenreBookLink.book_id == Book.id, GenreBookLink.genre_id == genre_id
GenreBookLink.book_id == Book.id, GenreBookLink.genre_id == genre_id # ty: ignore
)
)
@@ -101,13 +104,13 @@ def filter_books(
if q:
emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=q)["embedding"]
distance_col = Book.embedding.cosine_distance(emb)
statement = statement.where(Book.embedding.is_not(None))
distance_col = Book.embedding.cosine_distance(emb) # ty: ignore
statement = statement.where(Book.embedding.is_not(None)) # ty: ignore
keyword_match = case((Book.title.ilike(f"%{q}%"), 0), else_=1)
keyword_match = case((Book.title.ilike(f"%{q}%"), 0), else_=1) # ty: ignore
statement = statement.order_by(keyword_match, distance_col)
else:
statement = statement.order_by(Book.id)
statement = statement.order_by(Book.id) # ty: ignore
offset = (page - 1) * size
statement = statement.offset(offset).limit(size)
@@ -131,10 +134,18 @@ def create_book(
full_text = book.title + " " + book.description
emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=full_text)
db_book = Book(**book.model_dump(), embedding=emb["embedding"])
session.add(db_book)
session.commit()
session.refresh(db_book)
return BookRead(**db_book.model_dump(exclude={"embedding"}))
book_data = db_book.model_dump(exclude={"embedding", "preview_id"})
book_data["preview_urls"] = {
"png": f"/static/books/{db_book.preview_id}.png",
"jpeg": f"/static/books/{db_book.preview_id}.jpg",
"webp": f"/static/books/{db_book.preview_id}.webp",
} if db_book.preview_id else {}
return BookRead(**book_data)
@router.get(
@@ -145,9 +156,20 @@ def create_book(
)
def read_books(session: Session = Depends(get_session)):
"""Возвращает список всех книг"""
books = session.exec(select(Book)).all()
books = session.exec(select(Book)).all() # ty: ignore
books_data = []
for book in books:
book_data = book.model_dump(exclude={"embedding", "preview_id"})
book_data["preview_urls"] = {
"png": f"/static/books/{book.preview_id}.png",
"jpeg": f"/static/books/{book.preview_id}.jpg",
"webp": f"/static/books/{book.preview_id}.webp",
} if book.preview_id else {}
books_data.append(book_data)
return BookList(
books=[BookRead(**book.model_dump(exclude={"embedding"})) for book in books],
books=[BookRead(**book_data) for book_data in books_data],
total=len(books),
)
@@ -170,18 +192,23 @@ def get_book(
)
authors = session.scalars(
select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id)
select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id) # ty: ignore
).all()
author_reads = [AuthorRead(**author.model_dump()) for author in authors]
genres = session.scalars(
select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id)
select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id) # ty: ignore
).all()
genre_reads = [GenreRead(**genre.model_dump()) for genre in genres]
book_data = book.model_dump(exclude={"embedding"})
book_data = book.model_dump(exclude={"embedding", "preview_id"})
book_data["preview_urls"] = {
"png": f"/static/books/{book.preview_id}.png",
"jpeg": f"/static/books/{book.preview_id}.jpg",
"webp": f"/static/books/{book.preview_id}.webp",
} if book.preview_id else {}
book_data["authors"] = author_reads
book_data["genres"] = genre_reads
@@ -233,11 +260,21 @@ def update_book(
emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=full_text)
db_book.embedding = emb["embedding"]
if book_update.page_count is not None:
db_book.page_count = book_update.page_count
session.add(db_book)
session.commit()
session.refresh(db_book)
return BookRead(**db_book.model_dump(exclude={"embedding"}))
book_data = db_book.model_dump(exclude={"embedding", "preview_id"})
book_data["preview_urls"] = {
"png": f"/static/books/{db_book.preview_id}.png",
"jpeg": f"/static/books/{db_book.preview_id}.jpg",
"webp": f"/static/books/{db_book.preview_id}.webp",
} if db_book.preview_id else {}
return BookRead(**book_data)
@router.delete(
@@ -267,3 +304,69 @@ def delete_book(
session.delete(book)
session.commit()
return book_read
@router.post("/{book_id}/preview")
async def upload_book_preview(
current_user: RequireStaff,
file: UploadFile = File(...),
book_id: int = Path(..., gt=0),
session: Session = Depends(get_session)
):
if not (file.content_type or "").startswith("image/"):
raise HTTPException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Image required")
if (file.size or 0) > 32 * 1024 * 1024:
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "File larger than 10 MB")
file_uuid= uuid4()
tmp_path = BOOKS_PREVIEW_DIR / f"{file_uuid}.upload"
with open(tmp_path, "wb") as f:
shutil.copyfileobj(file.file, f)
book = session.get(Book, book_id)
if not book:
tmp_path.unlink()
raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found")
transcode_image(tmp_path)
tmp_path.unlink()
if book.preview_id:
for path in BOOKS_PREVIEW_DIR.glob(f"{book.preview_id}.*"):
if path.exists():
path.unlink(missing_ok=True)
book.preview_id = file_uuid
session.add(book)
session.commit()
return {
"preview": {
"png": f"/static/books/{file_uuid}.png",
"jpeg": f"/static/books/{file_uuid}.jpg",
"webp": f"/static/books/{file_uuid}.webp",
}
}
@router.delete("/{book_id}/preview")
async def remove_book_preview(
current_user: RequireStaff,
book_id: int = Path(..., gt=0),
session: Session = Depends(get_session)
):
book = session.get(Book, book_id)
if not book:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found")
if book.preview_id:
for path in BOOKS_PREVIEW_DIR.glob(f"{book.preview_id}.*"):
if path.exists():
path.unlink(missing_ok=True)
book.preview_id = None
session.add(book)
session.commit()
return {"preview_urls": []}
+22 -20
View File
@@ -40,109 +40,110 @@ def get_info(app) -> Dict:
@router.get("/", include_in_schema=False)
async def root(request: Request, app=Depends(lambda: get_app())):
"""Рендерит главную страницу"""
return templates.TemplateResponse(request, "index.html", get_info(app) | {"title": "LiB - Библиотека"})
return templates.TemplateResponse(request, "index.html", get_info(app) | {"request": request, "title": "LiB - Библиотека"})
@router.get("/unknown", include_in_schema=False)
async def unknown(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу 404 ошибки"""
return templates.TemplateResponse(request, "unknown.html", get_info(app) | {"title": "LiB - Страница не найдена"})
return templates.TemplateResponse(request, "unknown.html", get_info(app) | {"request": request, "title": "LiB - Страница не найдена"})
@router.get("/genre/create", include_in_schema=False)
async def create_genre(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу создания жанра"""
return templates.TemplateResponse(request, "create_genre.html", get_info(app) | {"title": "LiB - Создать жанр"})
return templates.TemplateResponse(request, "create_genre.html", get_info(app) | {"request": request, "title": "LiB - Создать жанр"})
@router.get("/genre/{genre_id}/edit", include_in_schema=False)
async def edit_genre(request: Request, genre_id: int, app=Depends(lambda: get_app())):
"""Рендерит страницу редактирования жанра"""
return templates.TemplateResponse(request, "edit_genre.html", get_info(app) | {"id": genre_id} | {"id": genre_id, "title": "LiB - Редактировать жанр"})
return templates.TemplateResponse(request, "edit_genre.html", get_info(app) | {"request": request, "title": "LiB - Редактировать жанр", "id": genre_id})
@router.get("/authors", include_in_schema=False)
async def authors(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу списка авторов"""
return templates.TemplateResponse(request, "authors.html", get_info(app) | {"title": "LiB - Авторы"})
return templates.TemplateResponse(request, "authors.html", get_info(app) | {"request": request, "title": "LiB - Авторы"})
@router.get("/author/create", include_in_schema=False)
async def create_author(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу создания автора"""
return templates.TemplateResponse(request, "create_author.html", get_info(app) | {"title": "LiB - Создать автора"})
return templates.TemplateResponse(request, "create_author.html", get_info(app) | {"request": request, "title": "LiB - Создать автора"})
@router.get("/author/{author_id}/edit", include_in_schema=False)
async def edit_author(request: Request, author_id: int, app=Depends(lambda: get_app())):
"""Рендерит страницу редактирования автора"""
return templates.TemplateResponse(request, "edit_author.html", get_info(app) | {"id": author_id, "title": "LiB - Редактировать автора"})
return templates.TemplateResponse(request, "edit_author.html", get_info(app) | {"request": request, "title": "LiB - Редактировать автора", "id": author_id})
@router.get("/author/{author_id}", include_in_schema=False)
async def author(request: Request, author_id: int, app=Depends(lambda: get_app())):
"""Рендерит страницу просмотра автора"""
return templates.TemplateResponse(request, "author.html", get_info(app) | {"id": author_id, "title": "LiB - Автор"})
return templates.TemplateResponse(request, "author.html", get_info(app) | {"request": request, "title": "LiB - Автор", "id": author_id})
@router.get("/books", include_in_schema=False)
async def books(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу списка книг"""
return templates.TemplateResponse(request, "books.html", get_info(app) | {"title": "LiB - Книги"})
return templates.TemplateResponse(request, "books.html", get_info(app) | {"request": request, "title": "LiB - Книги"})
@router.get("/book/create", include_in_schema=False)
async def create_book(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу создания книги"""
return templates.TemplateResponse(request, "create_book.html", get_info(app) | {"title": "LiB - Создать книгу"})
return templates.TemplateResponse(request, "create_book.html", get_info(app) | {"request": request, "title": "LiB - Создать книгу"})
@router.get("/book/{book_id}/edit", include_in_schema=False)
async def edit_book(request: Request, book_id: int, app=Depends(lambda: get_app())):
"""Рендерит страницу редактирования книги"""
return templates.TemplateResponse(request, "edit_book.html", get_info(app) | {"id": book_id, "title": "LiB - Редактировать книгу"})
return templates.TemplateResponse(request, "edit_book.html", get_info(app) | {"request": request, "title": "LiB - Редактировать книгу", "id": book_id})
@router.get("/book/{book_id}", include_in_schema=False)
async def book(request: Request, book_id: int, app=Depends(lambda: get_app())):
async def book(request: Request, book_id: int, app=Depends(lambda: get_app()), session=Depends(get_session)):
"""Рендерит страницу просмотра книги"""
return templates.TemplateResponse(request, "book.html", get_info(app) | {"id": book_id, "title": "LiB - Книга"})
book = session.get(Book, book_id)
return templates.TemplateResponse(request, "book.html", get_info(app) | {"request": request, "title": "LiB - Книга", "id": book_id, "img": book.preview_id})
@router.get("/auth", include_in_schema=False)
async def auth(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу авторизации"""
return templates.TemplateResponse(request, "auth.html", get_info(app) | {"title": "LiB - Авторизация"})
return templates.TemplateResponse(request, "auth.html", get_info(app) | {"request": request, "title": "LiB - Авторизация"})
@router.get("/2fa", include_in_schema=False)
async def set2fa(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу установки двухфакторной аутентификации"""
return templates.TemplateResponse(request, "2fa.html", get_info(app) | {"title": "LiB - Двухфакторная аутентификация"})
return templates.TemplateResponse(request, "2fa.html", get_info(app) | {"request": request, "title": "LiB - Двухфакторная аутентификация"})
@router.get("/profile", include_in_schema=False)
async def profile(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу профиля пользователя"""
return templates.TemplateResponse(request, "profile.html", get_info(app) | {"title": "LiB - Профиль"})
return templates.TemplateResponse(request, "profile.html", get_info(app) | {"request": request, "title": "LiB - Профиль"})
@router.get("/users", include_in_schema=False)
async def users(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу управления пользователями"""
return templates.TemplateResponse(request, "users.html", get_info(app) | {"title": "LiB - Пользователи"})
return templates.TemplateResponse(request, "users.html", get_info(app) | {"request": request, "title": "LiB - Пользователи"})
@router.get("/my-books", include_in_schema=False)
async def my_books(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу моих книг пользователя"""
return templates.TemplateResponse(request, "my_books.html", get_info(app) | {"title": "LiB - Мои книги"})
return templates.TemplateResponse(request, "my_books.html", get_info(app) | {"request": request, "title": "LiB - Мои книги"})
@router.get("/analytics", include_in_schema=False)
async def analytics(request: Request, app=Depends(lambda: get_app())):
"""Рендерит страницу аналитики выдач"""
return templates.TemplateResponse(request, "analytics.html", get_info(app) | {"title": "LiB - Аналитика"})
return templates.TemplateResponse(request, "analytics.html", get_info(app) | {"request": request, "title": "LiB - Аналитика"})
@router.get("/favicon.ico", include_in_schema=False)
@@ -181,6 +182,7 @@ async def api_info(app=Depends(lambda: get_app())):
description="Возвращает схему базы данных с описаниями полей",
)
async def api_schema():
"""Возвращает информацию для создания er-диаграммы"""
return generator.generate()
@@ -189,7 +191,7 @@ async def api_schema():
summary="Статистика сервиса",
description="Возвращает статистическую информацию о системе",
)
async def api_stats(session: Session = Depends(get_session)):
async def api_stats(session=Depends(get_session)):
"""Возвращает статистику системы"""
authors = select(func.count()).select_from(Author)
books = select(func.count()).select_from(Book)
+2
View File
@@ -13,6 +13,7 @@ from .captcha import (
prng,
)
from .describe_er import SchemaGenerator
from .image_processing import transcode_image
__all__ = [
"limiter",
@@ -28,4 +29,5 @@ __all__ = [
"REDEEM_TTL",
"prng",
"SchemaGenerator",
"transcode_image",
]
@@ -0,0 +1,81 @@
from pathlib import Path
from PIL import Image
TARGET_RATIO = 5 / 7
def crop_image(img: Image.Image, target_ratio: float = TARGET_RATIO) -> Image.Image:
w, h = img.size
current_ratio = w / h
if current_ratio > target_ratio:
new_w = int(h * target_ratio)
left = (w - new_w) // 2
right = left + new_w
top = 0
bottom = h
else:
new_h = int(w / target_ratio)
top = (h - new_h) // 2
bottom = top + new_h
left = 0
right = w
return img.crop((left, top, right, bottom))
def transcode_image(
src_path: str | Path,
*,
jpeg_quality: int = 85,
webp_quality: int = 80,
webp_lossless: bool = False,
resize_to: tuple[int, int] | None = None,
):
src_path = Path(src_path)
if not src_path.exists():
raise FileNotFoundError(src_path)
stem = src_path.stem
folder = src_path.parent
img = Image.open(src_path).convert("RGBA")
img = crop_image(img)
if resize_to:
img = img.resize(resize_to, Image.LANCZOS)
png_path = folder / f"{stem}.png"
img.save(
png_path,
format="PNG",
optimize=True,
interlace=1,
)
jpg_path = folder / f"{stem}.jpg"
img.convert("RGB").save(
jpg_path,
format="JPEG",
quality=jpeg_quality,
progressive=True,
optimize=True,
subsampling="4:2:0",
)
webp_path = folder / f"{stem}.webp"
img.save(
webp_path,
format="WEBP",
quality=webp_quality,
lossless=webp_lossless,
method=6,
)
return {
"png": png_path,
"jpeg": jpg_path,
"webp": webp_path,
}
+3
View File
@@ -10,6 +10,9 @@ from toml import load
load_dotenv()
BOOKS_PREVIEW_DIR = Path(__file__).parent / "static" / "books"
BOOKS_PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
with open("pyproject.toml", "r", encoding="utf-8") as f:
_pyproject = load(f)
+300 -9
View File
@@ -34,6 +34,7 @@ $(document).ready(() => {
const pathParts = window.location.pathname.split("/");
const bookId = parseInt(pathParts[pathParts.length - 1]);
let isDraggingOver = false;
let currentBook = null;
let cachedUsers = null;
let selectedLoanUserId = null;
@@ -48,6 +49,28 @@ $(document).ready(() => {
}
loadBookData();
setupEventHandlers();
setupCoverUpload();
}
function getPreviewUrl(book) {
if (!book.preview_urls) {
return null;
}
const priorities = ["webp", "jpeg", "jpg", "png"];
for (const format of priorities) {
if (book.preview_urls[format]) {
return book.preview_urls[format];
}
}
const availableFormats = Object.keys(book.preview_urls);
if (availableFormats.length > 0) {
return book.preview_urls[availableFormats[0]];
}
return null;
}
function setupEventHandlers() {
@@ -75,6 +98,270 @@ $(document).ready(() => {
$("#loan-due-date").val(future.toISOString().split("T")[0]);
}
function setupCoverUpload() {
const $container = $("#book-cover-container");
const $fileInput = $("#cover-file-input");
$fileInput.on("change", function (e) {
const file = e.target.files[0];
if (file) {
uploadCover(file);
}
$(this).val("");
});
$container.on("dragenter", function (e) {
e.preventDefault();
e.stopPropagation();
if (!window.canManage()) return;
isDraggingOver = true;
showDropOverlay();
});
$container.on("dragover", function (e) {
e.preventDefault();
e.stopPropagation();
if (!window.canManage()) return;
isDraggingOver = true;
});
$container.on("dragleave", function (e) {
e.preventDefault();
e.stopPropagation();
if (!window.canManage()) return;
const rect = this.getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
isDraggingOver = false;
hideDropOverlay();
}
});
$container.on("drop", function (e) {
e.preventDefault();
e.stopPropagation();
if (!window.canManage()) return;
isDraggingOver = false;
hideDropOverlay();
const files = e.dataTransfer?.files || [];
if (files.length > 0) {
const file = files[0];
if (!file.type.startsWith("image/")) {
Utils.showToast("Пожалуйста, загрузите изображение", "error");
return;
}
uploadCover(file);
}
});
}
function showDropOverlay() {
const $container = $("#book-cover-container");
$container.find(".drop-overlay").remove();
const $overlay = $(`
<div class="drop-overlay absolute inset-0 flex flex-col items-center justify-center z-20 pointer-events-none">
<div class="absolute inset-2 border-2 border-dashed border-gray-600 rounded-lg"></div>
<svg class="w-10 h-10 text-gray-600 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
</svg>
<span class="text-gray-700 text-sm font-medium text-center px-4">Отпустите для загрузки</span>
</div>
`);
$container.append($overlay);
}
function hideDropOverlay() {
$("#book-cover-container .drop-overlay").remove();
}
async function uploadCover(file) {
const $container = $("#book-cover-container");
const maxSize = 32 * 1024 * 1024;
if (file.size > maxSize) {
Utils.showToast("Файл слишком большой. Максимум 32 MB", "error");
return;
}
if (!file.type.startsWith("image/")) {
Utils.showToast("Пожалуйста, загрузите изображение", "error");
return;
}
const $loader = $(`
<div class="upload-loader absolute inset-0 bg-black bg-opacity-50 flex flex-col items-center justify-center z-20">
<svg class="animate-spin w-8 h-8 text-white mb-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-white text-sm">Загрузка...</span>
</div>
`);
$container.find(".upload-loader").remove();
$container.append($loader);
try {
const formData = new FormData();
formData.append("file", file);
const response = await Api.uploadFile(
`/api/books/${bookId}/preview`,
formData,
);
if (!response) {
return;
}
if (response.preview) {
currentBook.preview_urls = response.preview;
} else if (response.preview_urls) {
currentBook.preview_urls = response.preview_urls;
} else {
currentBook = response;
}
Utils.showToast("Обложка успешно загружена", "success");
renderBookCover(currentBook);
} catch (error) {
console.error("Upload error:", error);
Utils.showToast(error.message || "Ошибка загрузки обложки", "error");
} finally {
$container.find(".upload-loader").remove();
}
}
async function deleteCover() {
if (!confirm("Удалить обложку книги?")) {
return;
}
const $container = $("#book-cover-container");
const $loader = $(`
<div class="upload-loader absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center z-20">
<svg class="animate-spin w-8 h-8 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
`);
$container.find(".upload-loader").remove();
$container.append($loader);
try {
await Api.delete(`/api/books/${bookId}/preview`);
currentBook.preview_urls = null;
Utils.showToast("Обложка удалена", "success");
renderBookCover(currentBook);
} catch (error) {
console.error("Delete error:", error);
Utils.showToast(error.message || "Ошибка удаления обложки", "error");
} finally {
$container.find(".upload-loader").remove();
}
}
function renderBookCover(book) {
const $container = $("#book-cover-container");
const canManage = window.canManage();
const previewUrl = getPreviewUrl(book);
if (previewUrl) {
$container.html(`
<img
src="${Utils.escapeHtml(previewUrl)}"
alt="Обложка книги ${Utils.escapeHtml(book.title)}"
class="w-full h-full object-cover"
onerror="this.onerror=null; this.parentElement.querySelector('.cover-fallback').classList.remove('hidden'); this.classList.add('hidden');"
/>
<div class="cover-fallback hidden w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center absolute inset-0">
<svg class="w-20 h-20 text-white opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
</svg>
</div>
${
canManage
? `
<button
id="delete-cover-btn"
class="absolute top-2 right-2 w-7 h-7 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-10"
title="Удалить обложку"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all flex flex-col items-center justify-center cursor-pointer z-0" id="cover-replace-overlay">
<svg class="w-8 h-8 text-white opacity-0 group-hover:opacity-100 transition-opacity mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
</svg>
<span class="text-white text-center opacity-0 group-hover:opacity-100 transition-opacity text-xs font-medium pointer-events-none px-2">
Заменить
</span>
</div>
`
: ""
}
`);
if (canManage) {
$("#delete-cover-btn").on("click", function (e) {
e.stopPropagation();
deleteCover();
});
$("#cover-replace-overlay").on("click", function () {
$("#cover-file-input").trigger("click");
});
}
} else {
if (canManage) {
$container.html(`
<div
id="cover-upload-zone"
class="w-full h-full bg-gray-100 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-200 transition-all text-center relative"
>
<div class="absolute inset-2 border-2 border-dashed border-gray-300 rounded-lg pointer-events-none"></div>
<svg class="w-8 h-8 text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
</svg>
<span class="text-gray-500 text-xs font-medium px-2">
Добавить обложку
</span>
<span class="text-gray-400 text-xs mt-1 px-2">
или перетащите
</span>
</div>
`);
$("#cover-upload-zone").on("click", function () {
$("#cover-file-input").trigger("click");
});
} else {
$container.html(`
<div class="w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center">
<svg class="w-20 h-20 text-white opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
</svg>
</div>
`);
}
}
}
function loadBookData() {
Api.get(`/api/books/${bookId}`)
.then((book) => {
@@ -234,12 +521,16 @@ $(document).ready(() => {
function renderBook(book) {
$("#book-title").text(book.title);
$("#book-id").text(`ID: ${book.id}`);
renderBookCover(book);
if (book.page_count && book.page_count > 0) {
$("#book-page-count-value").text(book.page_count);
$("#book-page-count-text").removeClass("hidden");
} else {
$("#book-page-count-text").addClass("hidden");
}
$("#book-authors-text").text(
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен",
);
@@ -259,9 +550,9 @@ $(document).ready(() => {
$genres.empty();
book.genres.forEach((g) => {
$genres.append(`
<a href="/books?genre_id=${g.id}" class="inline-flex items-center bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-full text-sm transition-colors border border-gray-200">
${Utils.escapeHtml(g.name)}
</a>
<a href="/books?genre_id=${g.id}" class="inline-flex items-center bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-full text-sm transition-colors border border-gray-200">
${Utils.escapeHtml(g.name)}
</a>
`);
});
}
@@ -272,12 +563,12 @@ $(document).ready(() => {
$authors.empty();
book.authors.forEach((a) => {
$authors.append(`
<a href="/author/${a.id}" class="flex items-center bg-white hover:bg-gray-50 rounded-lg p-2 border border-gray-200 shadow-sm transition-colors group">
<div class="w-8 h-8 bg-gray-200 text-gray-600 group-hover:bg-gray-300 rounded-full flex items-center justify-center text-sm font-bold mr-2 transition-colors">
${a.name.charAt(0).toUpperCase()}
</div>
<span class="text-gray-800 font-medium text-sm">${Utils.escapeHtml(a.name)}</span>
</a>
<a href="/author/${a.id}" class="flex items-center bg-white hover:bg-gray-50 rounded-lg p-2 border border-gray-200 shadow-sm transition-colors group">
<div class="w-8 h-8 bg-gray-200 text-gray-600 group-hover:bg-gray-300 rounded-full flex items-center justify-center text-sm font-bold mr-2 transition-colors">
${a.name.charAt(0).toUpperCase()}
</div>
<span class="text-gray-800 font-medium text-sm">${Utils.escapeHtml(a.name)}</span>
</a>
`);
});
}
+61
View File
@@ -160,6 +160,67 @@ const Api = {
body: formData.toString(),
});
},
async uploadFile(endpoint, formData) {
const fullUrl = this.getBaseUrl() + endpoint;
const token = StorageHelper.get("access_token");
const headers = {};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
try {
const response = await fetch(fullUrl, {
method: "POST",
headers: headers,
body: formData,
credentials: "include",
});
if (response.status === 401) {
const refreshed = await Auth.tryRefresh();
if (refreshed) {
headers["Authorization"] =
`Bearer ${StorageHelper.get("access_token")}`;
const retryResponse = await fetch(fullUrl, {
method: "POST",
headers: headers,
body: formData,
credentials: "include",
});
if (retryResponse.ok) {
return retryResponse.json();
}
}
Auth.logout();
return null;
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
let errorMessage = `Ошибка ${response.status}`;
if (typeof errorData.detail === "string") {
errorMessage = errorData.detail;
} else if (Array.isArray(errorData.detail)) {
errorMessage = errorData.detail.map((e) => e.msg || e).join(", ");
} else if (errorData.detail?.message) {
errorMessage = errorData.detail.message;
} else if (errorData.message) {
errorMessage = errorData.message;
}
const error = new Error(errorMessage);
error.status = response.status;
throw error;
}
return response.json();
} catch (error) {
throw error;
}
},
};
const Auth = {
+2 -2
View File
@@ -9,8 +9,8 @@
<div class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-600">Период анализа:</label>
<select id="period-select" class="px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-gray-400 transition bg-white">
<option value="7">7 дней</option>
<option value="30" selected>30 дней</option>
<option value="7" selected>7 дней</option>
<option value="30">30 дней</option>
<option value="90">90 дней</option>
<option value="180">180 дней</option>
<option value="365">365 дней</option>
+95 -30
View File
@@ -7,9 +7,7 @@
<meta property="og:title" content="{{ title }}" />
<meta property="og:type" content="website" />
<meta property="og:description" content="Ваша персональная библиотека книг" />
<meta property="og:url" content="//{{ domain }}/" />
<!--<meta property="og:image" content="//{{ domain }}/img/{{ img }}.png" />-->
<meta property="og:url" content="{{ request.url.scheme }}://{{ domain }}/" />
<script
defer
src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"
@@ -23,42 +21,55 @@
<body
class="flex flex-col min-h-screen bg-gray-100"
x-data="{
user: null,
async init() {
document.addEventListener('auth:login', async (e) => {
this.user = e.detail;
this.user.avatar = await Utils.getGravatarUrl(this.user.email);
});
await Auth.init();
}
}"
user: null,
menuOpen: false,
async init() {
document.addEventListener('auth:login', async (e) => {
this.user = e.detail;
this.user.avatar = await Utils.getGravatarUrl(this.user.email);
});
await Auth.init();
}
}"
>
<header class="bg-gray-600 text-white p-4 shadow-md">
<div class="mx-auto pl-5 pr-3 flex justify-between items-center">
<a class="flex gap-4 items-center max-w-10 h-auto" href="/">
<img class="invert" src="/static/logo.svg" />
<h1 class="text-2xl font-bold">LiB</h1>
</a>
<nav>
<div class="mx-auto px-3 md:pl-5 md:pr-3 flex justify-between items-center">
<div class="flex items-center">
<button
@click="menuOpen = !menuOpen"
class="md:hidden flex gap-2 items-center hover:opacity-80 transition focus:outline-none"
:aria-expanded="menuOpen"
aria-label="Меню навигации"
>
<img class="invert max-w-10 h-auto" src="/static/logo.svg" />
<h1 class="text-xl font-bold">
<span class="text-gray-300 mr-1"></span>LiB
</h1>
</button>
<a class="hidden md:flex gap-4 items-center max-w-10 h-auto" href="/">
<img class="invert" src="/static/logo.svg" />
<h1 class="text-2xl font-bold">LiB</h1>
</a>
</div>
<nav class="hidden md:block">
<ul class="flex space-x-4">
<li>
<a href="/" class="hover:text-gray-200">Главная</a>
</li>
<li>
<a href="/books" class="hover:text-gray-200"
>Книги</a
>
<a href="/books" class="hover:text-gray-200">Книги</a>
</li>
<li>
<a href="/authors" class="hover:text-gray-200"
>Авторы</a
>
<a href="/authors" class="hover:text-gray-200">Авторы</a>
</li>
<li>
<a href="/api" class="hover:text-gray-200">API</a>
</li>
</ul>
</nav>
<div class="relative" x-data="{ open: false }">
<template x-if="!user">
<a
@@ -110,7 +121,7 @@
<div
x-show="open"
x-transition
class="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden text-gray-900"
class="absolute right-0 mt-2 w-56 max-w-[calc(100vw-2rem)] bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden text-gray-900"
style="display: none"
>
<div class="px-4 py-3 border-b border-gray-200">
@@ -235,17 +246,71 @@
</template>
</div>
</div>
<nav
x-show="menuOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2"
@click.outside="menuOpen = false"
class="md:hidden mt-4 pb-2 border-t border-gray-500"
style="display: none"
>
<ul class="flex flex-col space-y-1 pt-3">
<li>
<a
href="/"
@click="menuOpen = false"
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
>
Главная
</a>
</li>
<li>
<a
href="/books"
@click="menuOpen = false"
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
>
Книги
</a>
</li>
<li>
<a
href="/authors"
@click="menuOpen = false"
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
>
Авторы
</a>
</li>
<li>
<a
href="/api"
@click="menuOpen = false"
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
>
API
</a>
</li>
</ul>
</nav>
</header>
<main class="flex-grow">{% block content %}{% endblock %}</main>
<div
id="toast-container"
class="fixed bottom-5 right-5 flex flex-col gap-2 z-50"
class="fixed bottom-5 left-4 right-4 md:left-auto md:right-5 flex flex-col gap-2 z-50 items-center md:items-end"
></div>
<footer class="bg-gray-800 text-white p-4 mt-8">
<div class="container mx-auto text-center">
<p>&copy; 2026 LiB Library. Разработано в рамках дипломного проекта.
Код открыт под лицензией <a href="https://github.com/wowlikon/LiB/blob/main/LICENSE">MIT</a>.
<div class="container mx-auto text-center text-sm md:text-base">
<p>
&copy; 2026 LiB Library. Разработано в рамках дипломного проекта.
<br class="sm:hidden" />
Код открыт под лицензией
<a href="https://github.com/wowlikon/LiB/blob/main/LICENSE" class="underline hover:text-gray-300">MIT</a>.
</p>
</div>
</footer>
+23 -15
View File
@@ -66,29 +66,32 @@
class="flex flex-col items-center mb-6 md:mb-0 md:mr-8 flex-shrink-0 w-full md:w-auto"
>
<div
class="w-40 h-56 bg-gradient-to-br from-gray-400 to-gray-600 rounded-lg flex items-center justify-center shadow-md mb-4"
id="book-cover-container"
class="relative w-40 h-56 rounded-lg shadow-md mb-4 overflow-hidden flex items-center justify-center bg-gray-100 group"
>
<svg
class="w-20 h-20 text-white opacity-80"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
></path>
</svg>
<div class="w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center">
<svg
class="w-20 h-20 text-white opacity-80"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
></path>
</svg>
</div>
</div>
<input type="file" id="cover-file-input" class="hidden" accept="image/*" />
<div
id="book-status-container"
class="relative w-full flex justify-center z-10 mb-4"
></div>
<div id="book-actions-container" class="w-full"></div>
</div>
<div class="flex-1 w-full">
<div
class="flex flex-col md:flex-row md:items-start md:justify-between mb-2"
@@ -300,3 +303,8 @@
{% endblock %} {% block scripts %}
<script src="/static/page/book.js"></script>
{% endblock %}
{% block extra_head %}
{% if img %}
<meta property="og:image" content="{{ request.url.scheme }}://{{ domain }}/static/books/{{ img }}.jpg" />
{% endif %}
{% endblock %}
@@ -0,0 +1,33 @@
"""Book preview
Revision ID: abbc38275032
Revises: 6c616cc9d1f0
Create Date: 2026-02-01 14:41:14.611420
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel, pgvector
# revision identifiers, used by Alembic.
revision: str = 'abbc38275032'
down_revision: Union[str, None] = '6c616cc9d1f0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('book', sa.Column('preview_id', sa.Uuid(), nullable=True))
op.create_index(op.f('ix_book_preview_id'), 'book', ['preview_id'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_book_preview_id'), table_name='book')
op.drop_column('book', 'preview_id')
# ### end Alembic commands ###
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "LiB"
version = "0.8.1"
version = "0.9.0"
description = "Это простое API для управления авторами, книгами и их жанрами."
authors = [{ name = "wowlikon" }]
readme = "README.md"
Generated
+1 -5
View File
@@ -479,7 +479,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
{ url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
{ url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
{ url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" },
{ url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
{ url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
{ url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
@@ -487,7 +486,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
{ url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
{ url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" },
{ url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
{ url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
@@ -495,7 +493,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
{ url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
{ url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
{ url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" },
{ url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
{ url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
{ url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
@@ -503,7 +500,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
{ url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
{ url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
{ url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" },
{ url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
{ url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
@@ -631,7 +627,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/e8/ef/324f4a28ed0152a32
[[package]]
name = "lib"
version = "0.8.0"
version = "0.9.0"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },