Compare commits

..
Author SHA1 Message Date
crisleo94 26c5f6f06b Merge pull request 'feat: implement mobile reader' (#27) from feat-mobile-reader into main
Reviewed-on: #27
2026-06-20 18:34:58 +00:00
crisleo94 22ded87250 feat: implement mobile reader
- implement mobile epub reader
2026-06-20 13:33:44 -05:00
crisleo94 84c0497f21 Merge pull request 'Feature/uv implementation' (#26) from feature/uv-implementation into main
Reviewed-on: #26
2026-06-04 12:10:51 +00:00
crisleo94 724eae1142 fix: remove binary files 2026-06-04 06:45:27 -05:00
crisleo94 654cfec147 fix: cleanup 2026-06-04 06:42:27 -05:00
crisleo94 f989b144cf fix: pdf bookmark
for pdfs only select pages not text
2026-06-03 23:01:12 -05:00
crisleo94 f091386974 feat: pdf reader 2026-06-03 22:46:38 -05:00
crisleo94 d9c66e68a6 fix: floating bookmarks
- update floating bookmarks
- fix search suggestions
- fix filters
2026-06-03 22:33:24 -05:00
crisleo94 054e7689bd feat: floating bookmarks
add floating bookmarks
2026-06-03 22:19:19 -05:00
crisleo94 6b4c0c43f8 feat: uv config other feats
- add uv configuration for the backend
- update frontend to make auth work
- add new auth endpoints
- add bookmars feat
- add reader feat
2026-06-03 22:06:01 -05:00
crisleo94 730c748f5f Merge pull request 'Implement: Customizable Mobile Reading Experience' (#25) from feature/customizable-mobile-reading-experience into main
Reviewed-on: #25
Reviewed-by: crisleo94 <cristhian.reinoso@codescripters.org>
2026-05-29 13:48:05 +00:00
Marko (Hermes Implementer) 7cdc3d0969 Remove stale web/ directory — content already ported to frontend/
Reid's second review flagged that the old web/ files still had:
- XSS vulnerability (no DOMPurify)
- CSS typo (landascape)
- API endpoint mismatches

All fix: delete web/ entirely. The correct, sanitized, fixed versions
already live in frontend/src/.
2026-05-29 07:04:53 +00:00
Marko (Hermes Implementer) a7864bf5cd Address Reid's PR #25 review comments
🔴 Blocking fixes:
- Add reader app migration (apps/reader/migrations/0001_initial.py)
- Remove duplicate ReadingSettings from apps.books to resolve model clash
  with apps.reader.ReadingSettings (keep richer reader version)
- Redirect books serializers/views to use reader app's ReadingSettings
- Update frontend API endpoints to match main's backend routes:
  /api/books/ebooks/{id}/toc/ (chapters)
  /api/books/ebooks/{id}/content/?page=N (chapter content)
  /api/books/ebooks/{id}/progress/ (GET/PATCH progress)
- Add DOMPurify sanitization for dangerouslySetInnerHTML (XSS fix)

🟡 Non-blocking fixes:
- Fix CSS typo: landascape -> landscape in reader.css
- Add null-safe fallback for book.title in ReadingPage
2026-05-29 06:37:58 +00:00
Marko (Hermes Implementer) edac7cb08a Merge branch 'main' into feature/customizable-mobile-reading-experience
Resolve merge conflicts:
- backend/apps/books/: Keep main's models (EBook, BookChapter, etc.)
- frontend/src/App.tsx: Keep /read/:id route + main's all routes
- web/ files: Accept deletion (content ported to frontend/)
2026-05-29 05:59:10 +00:00
Marko (Hermes Implementer) 74fdae61db Resolve merge conflicts for PR #25: customizable mobile reading experience
- Backend: Replace PR's Book-only models with main's full models
  (EBook, BookChapter, DownloadRecord, ReadingProgress, ReadingSettings)
- Backend: Add reader app (ReadingSettings model, serializer, view, URL)
- Frontend: Port reader feature files from web/ to frontend/ (api, hooks,
  components/reader/, pages/ReadingPage, types/reader, reader.css)
- Frontend: Add /read/:id route to App.tsx for chapter-based reading view
- All imports adjusted for frontend/src directory structure
2026-05-29 05:53:14 +00:00
crisleo94 3f626259e8 Merge pull request 'Implement: US: Mobile Book Search and Discovery' (#24) from feature/mobile-search-discovery into main
Reviewed-on: #24
Reviewed-by: crisleo94 <cristhian.reinoso@codescripters.org>
2026-05-29 05:13:02 +00:00
Marko (Hermes Implementer) ade810a013 fix: address PR #24 review comments - SpeechRecognition types, CSS hover over direct DOM, shared useDebounce 2026-05-29 05:10:06 +00:00
crisleo94 6a223d7237 Merge branch 'main' into feature/mobile-search-discovery 2026-05-29 04:50:59 +00:00
crisleo94 656d20879f Merge pull request 'Implement: US: Integrate Expo Mobile Application into Monorepo (#16)' (#23) from feature/expo-integration into main
Reviewed-on: #23
Reviewed-by: crisleo94 <cristhian.reinoso@codescripters.org>
2026-05-29 04:50:49 +00:00
crisleo94 2eeb3fb7d6 Merge pull request 'Fix: Missing API methods for Book Search and Discovery (re-opened #5)' (#14) from fix/missing-api-methods into main
Reviewed-on: #14
Reviewed-by: crisleo94 <cristhian.reinoso@codescripters.org>
2026-05-29 04:25:37 +00:00
Marko (Hermes Implementer) 658f77a746 feat: mobile book search and discovery with voice search, suggestions, and responsive layout 2026-05-29 02:55:50 +00:00
Marko (Hermes Implementer) b29ea8211c feat: customizable mobile reading experience
- Backend: Chapter, ReadingProgress, ReadingSettings models
- Backend: Chapter API (TOC + content), progress tracking, settings CRUD
- Frontend: ReadingPage with chapter navigation
- Frontend: TableOfContents drawer
- Frontend: ReadingSettingsPanel (theme, font, size, orientation)
- Frontend: Custom hooks for settings, chapters, progress tracking
- CSS: Mobile-first reading view with sepia/dark/light/paper themes
- Route: /reader/:bookId reading view from book detail page
- Docs: 001-customizable-mobile-reading-experience.md
2026-05-29 02:55:36 +00:00
Marko (Hermes Implementer) fa82fab44a feat: Integrate Expo mobile application into monorepo (#16)
- Add mobile/ directory with Expo React Native project
- Create API client with JWT auth and token refresh using AsyncStorage
- Implement AuthContext for login/register/logout flow
- Add screens: Login, Register, Library, Search, Settings
- Set up React Navigation with AuthStack and MainTabs
- Create packages/shared/ with shared types and utilities
- Add shared validation utilities (email, password strength)
- Update root package.json workspaces to include mobile + shared
- Add spec document docs/backend/009-expo-integration.md
2026-05-29 02:50:09 +00:00
Marko (Hermes Implementer) 670101b61e fix: add missing book API methods and types (searchBooks, getBook, getGenres, getAuthors) 2026-05-29 00:08:32 +00:00
markoandreid 332b539880 Implement: US: Book Search and Discovery (#13)
Reviewed and merged by Reid (Hermes Reviewer)

Co-authored-by: crisleo-hermes <hermes@codescripters.org>
Co-committed-by: crisleo-hermes <hermes@codescripters.org>
2026-05-26 06:21:32 +00:00
markoandreid f84a593c5d Implement: Refactor: Consolidate Duplicate Backend and Frontend Implementations (#11)
Reviewed and merged by Reid (Hermes Reviewer)

Co-authored-by: crisleo-hermes <hermes@codescripters.org>
Co-committed-by: crisleo-hermes <hermes@codescripters.org>
2026-05-26 03:54:25 +00:00
255 changed files with 55630 additions and 3849 deletions
+10
View File
@@ -0,0 +1,10 @@
POSTGRES_DB=postres
POSTGRES_USER=root
POSTGRES_PASSWORD=root
DJANGO_SECRET_KEY=changeme
DJANGO_DEBUG=True
DB_HOST=localhost
DB_PORT=5432
VITE_API_URL=http://localhost:8000/api
+40
View File
@@ -0,0 +1,40 @@
node_modules
dist
frontend/dist
frontend/dist/assets
frontend/dist/assets/index.html
frontend/dist/assets/index.html.gz
frontend/dist/assets/index.html.br
frontend/dist/assets/index.html.brotli
frontend/dist/assets/index.html.gzip
frontend/dist/assets/index.html.br
frontend/dist/assets/index.html.brotli
frontend/dist/assets/index.html.gzip
frontend/dist/assets/index.html.br
frontend/dist/assets/index.html.brotli
frontend/dist/assets/index.html.gzip
frontend/dist/assets/index.html.br
frontend/dist/assets/index.html.brotli
frontend/dist/assets/index.html.gzip
frontend/dist/assets/index.html.br
frontend/dist/assets/index.html.brotli
frontend/dist/assets/index.html.gzip
mobile/dist
backend/media
backend/staticfiles
backend/media
backend/staticfiles
.pycache__
__pycache__
*.pyc
*.pyo
*.pyd
*.db
*.sqlite3
*.log
*.env
*.DS_Store
*.vscode
*.idea
*.swp
*.swo
+105
View File
@@ -0,0 +1,105 @@
# AGENTS.md — Cloud Reader monorepo
Guidance for AI agents and contributors working in this repository.
## Repository layout
```
cloud-reader/
├── backend/ # Django REST API (canonical backend)
├── frontend/ # React + Vite + TypeScript (web)
├── mobile/ # Expo React Native app
├── packages/shared/ # @cloud-reader/shared types & utils
├── docs/ # Feature specs and architecture notes
└── docker-compose.yml
```
- **Backend** is the source of truth for API contracts, auth, and persistence.
- **Frontend** and **mobile** consume the same REST API; share domain types via `@cloud-reader/shared` where practical.
- Do not reintroduce removed `api/` or `web/` directories.
## Documentation conventions
| Location | Purpose |
|----------|---------|
| `docs/NNN-*.md` | Cross-cutting or product specs (e.g. `001-customizable-mobile-reading-experience.md`) |
| `docs/backend/NNN-*.md` | Backend feature specs; use next sequential number (currently `010`) |
| `docs/mobile/NNN-*.md` | Mobile (Expo) feature specs; use next sequential number (currently `010`) |
| `docs/frontend/*.md` | Frontend-specific specs |
When adding a **major backend feature**:
1. Implement in `backend/apps/<app>/`.
2. Add or update a numbered spec under `docs/backend/`.
3. Include: objective, API contracts, models/services touched, env vars, and verification steps.
Do not edit plan files in `.cursor/plans/` unless explicitly asked.
## Backend (Django)
- **Python:** 3.12+, managed with `uv` (`backend/pyproject.toml`, `backend/uv.lock`).
- **Settings:** `backend/config/settings.py` (pydantic-settings) + `backend/config/django.py`.
- **Apps:** `users`, `books`, `annotations`, `reader`.
- **Auth:** JWT via `djangorestframework-simplejwt`; register/login at `/api/auth/`.
- **Default permission:** `IsAuthenticated` — public endpoints must set `AllowAny` explicitly.
- **URL routing:** Single `DefaultRouter` in `books/urls.py`; register specific prefixes (e.g. `ebooks`) **before** the empty `""` book route to avoid `{pk}` shadowing.
- **Services:** Put non-trivial logic in `apps/<app>/services/` (not in views/serializers).
- **Migrations:** Run after model changes; prefer reusing existing JSON fields (e.g. `EBook.metadata_json`) before new columns.
- **Tests:** Only add when requested or when they cover non-obvious behavior.
### Books domain
- **`Book`:** Catalog/discovery entity (genres, reading status).
- **`EBook`:** Per-user uploaded file (EPUB/PDF); primary import path via `POST /api/books/ebooks/`.
- **`metadata_json` on EBook:** External metadata (Open Library, etc.).
- **`cover_image` on EBook:** Stored file; library UI reads it from list/detail serializers.
## Frontend (web)
- **Entry:** `frontend/src/main.tsx` mounts `App.tsx` (React Router + auth).
- **API client:** `frontend/src/api/client.ts` (axios, JWT refresh, base URL `/api`).
- **Auth routes:** `/auth` (not `/login`).
- **Imports:** Upload via `booksApi.uploadEBook`; library lists user `EBook`s.
- **Styling:** Mix of inline styles and CSS modules; match surrounding patterns.
- Do not add tests or new `.md` files unless requested.
## Mobile (Expo)
- Uses `@cloud-reader/shared` and mirrors web API patterns.
- Token storage: AsyncStorage; base URL from `EXPO_PUBLIC_API_URL`.
## Shared package
- `packages/shared/src/types.ts` — domain types for web/mobile.
- `packages/shared/src/utils.ts` — API endpoint constants, helpers.
- Keep camelCase in TS; backend JSON may use snake_case (DRF default).
## Code change principles
1. **Minimize scope** — smallest correct diff; no drive-by refactors.
2. **Match conventions** — read neighboring code before adding new patterns.
3. **No over-engineering** — no extra abstractions for one-off use.
4. **Comments** — only for non-obvious business logic.
5. **Imports** — remove unused imports; delete dead code after refactors.
6. **Secrets** — never commit `.env`; document vars in `.env.example` only.
## Git & PRs
- Commit only when the user asks.
- Do not force-push `main`/`master`.
- Use `gh` for GitHub PRs when requested.
## Postman
- Use camelCase operation names (`listSomething`, `createSomething`).
- Ask which env vars the user uses; output JSON to copy, not a new file.
## Commands (run locally when needed)
```bash
# Backend
cd backend && uv sync && uv run python manage.py migrate && uv run python manage.py runserver
# Frontend
cd frontend && yarn install && yarn dev
```
+71 -16
View File
@@ -1,38 +1,93 @@
# Cloud Reader # Cloud Reader
A full-stack cloud-based e-book reader application with cross-device synchronization. A full-stack e-book reader application with cross-device sync. Upload EPUB/PDF files, track reading progress, bookmark passages, take notes, and customize your reading experience.
## Architecture ## Architecture
- **Backend**: Django 5.x + Django REST Framework ```
- **Frontend**: React with TypeScript cloud-reader/
- **Database**: PostgreSQL ├── backend/ # Django REST API (canonical backend)
- **Monorepo**: Yarn workspaces │ ├── config/ # Django project settings
│ ├── apps/
│ │ ├── users/ # User auth (JWT)
│ │ ├── books/ # Books, e-books, reading progress, settings
│ │ └── annotations/ # Bookmarks and notes
│ ├── manage.py
│ └── requirements.txt
├── frontend/ # React + Vite + TypeScript (web frontend)
│ ├── src/
│ │ ├── api/ # API client (axios with JWT refresh)
│ │ ├── components/ # Reusable components
│ │ ├── context/ # Auth and annotations context
│ │ ├── hooks/ # Custom hooks
│ │ ├── pages/ # Route pages (Library, Reader, AddBook, Auth, Settings)
│ │ └── types/ # TypeScript type definitions
│ └── package.json
├── mobile/ # Expo React Native app (mobile frontend)
│ ├── src/
│ │ ├── api/ # API client (axios with JWT refresh via AsyncStorage)
│ │ ├── components/ # Reusable UI components
│ │ ├── context/ # Auth context
│ │ ├── hooks/ # Custom hooks
│ │ ├── navigation/ # React Navigation (Auth stack + Main tabs)
│ │ ├── screens/ # Screen-level components (Login, Library, etc.)
│ │ └── types/ # Mobile-specific types
│ ├── App.tsx
│ └── app.json
├── packages/
│ └── shared/ # @cloud-reader/shared — domain types & utilities
│ └── src/
│ ├── types.ts # Shared domain types (Book, User, Bookmark, Note, etc.)
│ └── utils.ts # Date formatting, validation, API endpoint constants
├── package.json # Root — yarn workspaces config
└── docker-compose.yml
```
## Getting Started ## Quick Start
### Prerequisites ### Docker (recommended)
- Python 3.12+ ```bash
- Node.js 20+ cp env.example .env
- PostgreSQL 15+ ```
```bash
docker compose up --build
```
- **Frontend:** http://localhost:5173
- **Backend API:** http://localhost:8000/api/
### Backend Setup ### Backend (standalone)
```bash ```bash
cd backend cd backend
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
python manage.py migrate python manage.py migrate
python manage.py runserver python manage.py runserver
``` ```
### Frontend Setup ### Frontend (standalone)
```bash ```bash
cd frontend cd frontend
yarn install yarn install
yarn dev yarn dev
``` ```
## Projects ### Mobile (Expo)
```bash
# From monorepo root — installs all workspaces including mobile
yarn install
See [Gitea Issues](https://gitea-dev.codescripters.org/crisleo-hermes/cloud-reader/issues) for the full roadmap. # Start Expo dev server
yarn workspace @cloud-reader/mobile start
# Or cd into mobile and run directly
cd mobile
npx expo start
```
> The mobile app requires the backend to be running. Set `EXPO_PUBLIC_API_URL` environment variable in your shell or `.env` file to point to the backend (defaults to `http://10.0.2.2:8000` for Android emulator).
## Migration Notes
Consolidated from duplicate `api/` + `web/` into single `backend/` + `frontend/` canonical structure.
- `backend/` kept as canonical; `api/` features (e-book uploads, reading progress, reading settings) merged in.
- `frontend/` kept as canonical; `web/` pages (Library, Reader, AddBook, Auth, Settings) merged in.
- `api/` and `web/` directories removed.
- `mobile/` added as Expo React Native app with shared `@cloud-reader/shared` package.
-20
View File
@@ -1,20 +0,0 @@
from django.contrib import admin
from books.models import Book, ReadingProgress, ReadingSettings
@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
list_display = ["title", "author", "user", "created_at"]
list_filter = ["created_at", "user"]
search_fields = ["title", "author"]
@admin.register(ReadingProgress)
class ReadingProgressAdmin(admin.ModelAdmin):
list_display = ["book", "user", "current_position", "updated_at"]
@admin.register(ReadingSettings)
class ReadingSettingsAdmin(admin.ModelAdmin):
list_display = ["user", "font_size", "font_style", "background_color"]
-1
View File
@@ -1 +0,0 @@
# management/__init__.py
@@ -1 +0,0 @@
# management/commands/__init__.py
-134
View File
@@ -1,134 +0,0 @@
"""
Management command to seed sample book data for development and testing.
"""
from __future__ import annotations
from typing import Any
from django.core.management.base import BaseCommand
from books.models import Book, ReadingStatus
SAMPLE_BOOKS: list[dict[str, Any]] = [
{
"title": "The Great Gatsby",
"author": "F. Scott Fitzgerald",
"genre": "Classic Literature",
"description": "A story of the mysteriously wealthy Jay Gatsby and his love for the beautiful Daisy Buchanan.",
"reading_status": ReadingStatus.FINISHED,
},
{
"title": "To Kill a Mockingbird",
"author": "Harper Lee",
"genre": "Classic Literature",
"description": "The unforgettable novel of a childhood in a sleepy Southern town and the crisis of conscience that rocked it.",
"reading_status": ReadingStatus.FINISHED,
},
{
"title": "1984",
"author": "George Orwell",
"genre": "Science Fiction",
"description": "A dystopian social science fiction novel and cautionary tale about the future of totalitarianism.",
"reading_status": ReadingStatus.READING,
},
{
"title": "Dune",
"author": "Frank Herbert",
"genre": "Science Fiction",
"description": "Set on the desert planet Arrakis, it is one of the world's best-selling science fiction novels.",
"reading_status": ReadingStatus.FINISHED,
},
{
"title": "The Hobbit",
"author": "J.R.R. Tolkien",
"genre": "Fantasy",
"description": "Bilbo Baggins is swept into a quest to reclaim the lost Dwarf Kingdom of Erebor from the fearsome dragon Smaug.",
"reading_status": ReadingStatus.FINISHED,
},
{
"title": "Pride and Prejudice",
"author": "Jane Austen",
"genre": "Romance",
"description": "A romantic novel of manners that follows the character development of Elizabeth Bennet.",
"reading_status": ReadingStatus.WANT_TO_READ,
},
{
"title": "The Martian",
"author": "Andy Weir",
"genre": "Science Fiction",
"description": "An astronaut becomes stranded alone on Mars and must find a way to signal that he is alive.",
"reading_status": ReadingStatus.READING,
},
{
"title": "The Catcher in the Rye",
"author": "J.D. Salinger",
"genre": "Classic Literature",
"description": "The story of Holden Caulfield's experiences in New York City after being expelled from prep school.",
"reading_status": ReadingStatus.DNF,
},
{
"title": "The Alchemist",
"author": "Paulo Coelho",
"genre": "Philosophy",
"description": "A young Andalusian shepherd follows his dream to find treasure at the Egyptian pyramids.",
"reading_status": ReadingStatus.WANT_TO_READ,
},
{
"title": "Neuromancer",
"author": "William Gibson",
"genre": "Science Fiction",
"description": "The novel that launched the cyberpunk genre, following washed-up computer hacker Henry Case.",
"reading_status": ReadingStatus.FINISHED,
},
{
"title": "The Name of the Wind",
"author": "Patrick Rothfuss",
"genre": "Fantasy",
"description": "The tale of the magically gifted young man who grows to be the most notorious wizard his world has ever seen.",
"reading_status": ReadingStatus.READING,
},
{
"title": "Gone Girl",
"author": "Gillian Flynn",
"genre": "Thriller",
"description": "A mystery thriller about a wife's disappearance on the day of her fifth wedding anniversary.",
"reading_status": ReadingStatus.FINISHED,
},
{
"title": "Sapiens",
"author": "Yuval Noah Harari",
"genre": "Non-Fiction",
"description": "A brief history of humankind, exploring how biology and history have defined us.",
"reading_status": ReadingStatus.FINISHED,
},
{
"title": "The Road",
"author": "Cormac McCarthy",
"genre": "Post-Apocalyptic",
"description": "A father and his young son walk alone through burned America heading toward the coast.",
"reading_status": ReadingStatus.WANT_TO_READ,
},
{
"title": "The Three-Body Problem",
"author": "Cixin Liu",
"genre": "Science Fiction",
"description": "The first novel in the Remembrance of Earth's Past trilogy, a blend of physics and alien contact.",
"reading_status": ReadingStatus.READING,
},
]
class Command(BaseCommand):
"""Seed the database with sample books."""
help = "Seeds the database with sample books for development and testing."
def handle(self, *args: Any, **options: Any) -> str | None:
for i, book_data in enumerate(SAMPLE_BOOKS):
Book.objects.get_or_create(
title=book_data["title"],
author=book_data["author"],
defaults=book_data,
)
count = Book.objects.count()
self.stdout.write(self.style.SUCCESS(f"Seeded {count} books into the database."))
-56
View File
@@ -1,56 +0,0 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies: list = []
operations = [
migrations.CreateModel(
name="Book",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(db_index=True, max_length=500)),
("author", models.CharField(db_index=True, max_length=300)),
("genre", models.CharField(db_index=True, max_length=100)),
("description", models.TextField(blank=True, default="")),
(
"reading_status",
models.CharField(
choices=[
("want_to_read", "Want to Read"),
("reading", "Reading"),
("finished", "Finished"),
("dnf", "Did Not Finish"),
],
db_index=True,
default="want_to_read",
max_length=20,
),
),
("cover_url", models.URLField(blank=True, default="")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ["title"],
"indexes": [
models.Index(
fields=["title", "author", "genre"],
name="books_book_title_2cc25e_idx",
)
],
},
),
]
-1
View File
@@ -1 +0,0 @@
# migrations/__init__.py
-111
View File
@@ -1,111 +0,0 @@
from enum import StrEnum
from pathlib import Path
from django.conf import settings
from django.core.validators import FileExtensionValidator
from django.db import models
from django.db.models.signals import post_delete
from django.dispatch import receiver
class FontStyle(StrEnum):
SANS_SERIF = "sans-serif"
SERIF = "serif"
MONOSPACE = "monospace"
class BackgroundColor(StrEnum):
WHITE = "#ffffff"
SEPIA = "#f4e4c1"
DARK = "#1a1a2e"
GREEN = "#c7edcc"
class Book(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="books",
)
title = models.CharField(max_length=512)
author = models.CharField(max_length=256, blank=True, default="")
file = models.FileField(
upload_to="books/%Y/%m/%d/",
validators=[FileExtensionValidator(allowed_extensions=["epub", "pdf"])],
)
cover_image = models.ImageField(upload_to="covers/%Y/%m/%d/", blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["user", "-created_at"]),
]
def __str__(self) -> str:
return self.title
def filename(self) -> str:
return Path(self.file.name).name
@receiver(post_delete, sender=Book)
def _auto_delete_file_on_delete(sender: type[Book], instance: Book, **kwargs: object) -> None:
"""Delete the uploaded file when the Book record is deleted."""
if instance.file:
instance.file.delete(save=False)
if instance.cover_image:
instance.cover_image.delete(save=False)
class ReadingProgress(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="reading_progress",
)
book = models.OneToOneField(
Book,
on_delete=models.CASCADE,
related_name="reading_progress",
)
current_position = models.FloatField(default=0.0)
"""Position in the book as a percentage (0.0 to 100.0)"""
last_page = models.IntegerField(default=0)
"""Last page/paragraph index for granular tracking"""
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name_plural = "reading progress"
unique_together = [("user", "book")]
def __str__(self) -> str:
return f"{self.book.title}{self.current_position:.1f}%"
class ReadingSettings(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="reading_settings",
)
font_size = models.IntegerField(default=18)
"""Font size in pixels (min 12, max 36)"""
font_style = models.CharField(
max_length=20,
choices=[(s.value, s.name.replace("_", " ").title()) for s in FontStyle],
default=FontStyle.SANS_SERIF.value,
)
background_color = models.CharField(
max_length=7,
choices=[(c.value, c.name.title()) for c in BackgroundColor],
default=BackgroundColor.WHITE.value,
)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name_plural = "reading settings"
def __str__(self) -> str:
return f"Settings for {self.user}"
-136
View File
@@ -1,136 +0,0 @@
from rest_framework import serializers
from books.models import Book, FontStyle, BackgroundColor, ReadingProgress, ReadingSettings
class BookListSerializer(serializers.ModelSerializer):
"""Lightweight serializer for the library listing — no file or progress data."""
filename = serializers.CharField(read_only=True)
progress = serializers.SerializerMethodField()
class Meta:
model = Book
fields = [
"id",
"title",
"author",
"filename",
"cover_image",
"created_at",
"progress",
]
def get_progress(self, obj: Book) -> float | None:
try:
return obj.reading_progress.current_position
except ReadingProgress.DoesNotExist:
return None
class BookDetailSerializer(serializers.ModelSerializer):
"""Full serializer for the reader view — includes file URL and progress."""
filename = serializers.CharField(read_only=True)
file_url = serializers.SerializerMethodField()
progress = serializers.SerializerMethodField()
class Meta:
model = Book
fields = [
"id",
"title",
"author",
"filename",
"file_url",
"cover_image",
"created_at",
"updated_at",
"progress",
]
def get_file_url(self, obj: Book) -> str:
request = self.context.get("request")
if request and obj.file:
return request.build_absolute_uri(obj.file.url)
return ""
def get_progress(self, obj: Book) -> dict | None:
try:
rp = obj.reading_progress
return {
"current_position": rp.current_position,
"last_page": rp.last_page,
}
except ReadingProgress.DoesNotExist:
return None
class BookUploadSerializer(serializers.ModelSerializer):
"""Serializer for uploading a new book."""
class Meta:
model = Book
fields = ["title", "author", "file", "cover_image"]
extra_kwargs = {
"title": {"required": True},
"file": {"required": True},
}
def validate_file(self, value: object) -> object:
import os
if isinstance(value, type(None)):
return value
ext = os.path.splitext(str(getattr(value, 'name', '')))[1].lower()
if ext not in (".epub", ".pdf"):
raise serializers.ValidationError(
"Only EPUB and PDF files are supported."
)
return value
def create(self, validated_data: dict) -> Book:
validated_data["user"] = self.context["request"].user
return super().create(validated_data)
class ReadingProgressSerializer(serializers.ModelSerializer):
class Meta:
model = ReadingProgress
fields = ["current_position", "last_page"]
extra_kwargs = {
"current_position": {"required": True, "min_value": 0.0, "max_value": 100.0},
}
def validate_current_position(self, value: float) -> float:
if value < 0.0 or value > 100.0:
raise serializers.ValidationError(
"Position must be between 0.0 and 100.0."
)
return value
class ReadingSettingsSerializer(serializers.ModelSerializer):
class Meta:
model = ReadingSettings
fields = ["font_size", "font_style", "background_color"]
def validate_font_size(self, value: int) -> int:
if value < 12 or value > 36:
raise serializers.ValidationError("Font size must be between 12 and 36.")
return value
def validate_font_style(self, value: str) -> str:
valid = [s.value for s in FontStyle]
if value not in valid:
raise serializers.ValidationError(
f"Font style must be one of: {', '.join(valid)}"
)
return value
def validate_background_color(self, value: str) -> str:
valid = [c.value for c in BackgroundColor]
if value not in valid:
raise serializers.ValidationError(
f"Background color must be one of: {', '.join(valid)}"
)
return value
-15
View File
@@ -1,15 +0,0 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from books.views import BookViewSet, ReadingSettingsViewSet
router = DefaultRouter()
router.register(r"books", BookViewSet, basename="book")
urlpatterns = [
path("", include(router.urls)),
path("settings/", ReadingSettingsViewSet.as_view({
"get": "list",
"patch": "partial_update",
}), name="reading-settings"),
]
-88
View File
@@ -1,88 +0,0 @@
from django.shortcuts import get_object_or_404
from rest_framework import parsers, permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
from books.models import Book, ReadingProgress, ReadingSettings
from books.serializers import (
BookDetailSerializer,
BookListSerializer,
BookUploadSerializer,
ReadingProgressSerializer,
ReadingSettingsSerializer,
)
class IsBookOwner(permissions.BasePermission):
"""Only the owner of a book can access it."""
def has_object_permission(
self, request: Request, view: object, obj: Book
) -> bool:
return obj.user == request.user
class BookViewSet(viewsets.ModelViewSet):
"""API endpoint for managing user books."""
parser_classes = [parsers.MultiPartParser, parsers.FormParser, parsers.JSONParser]
permission_classes = [permissions.IsAuthenticated, IsBookOwner]
def get_serializer_class(self) -> type:
if self.action == "create":
return BookUploadSerializer
if self.action == "list":
return BookListSerializer
return BookDetailSerializer
def get_queryset(self):
return (
Book.objects.filter(user=self.request.user)
.select_related("reading_progress", "user")
.prefetch_related()
)
@action(detail=True, methods=["get", "patch"])
def progress(self, request: Request, pk: int | None = None) -> Response:
"""Get or update reading progress for a specific book."""
book = self.get_object()
progress, _created = ReadingProgress.objects.get_or_create(
user=request.user,
book=book,
)
if request.method == "GET":
serializer = ReadingProgressSerializer(progress)
return Response(serializer.data)
serializer = ReadingProgressSerializer(progress, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
class ReadingSettingsViewSet(viewsets.GenericViewSet):
"""API endpoint for user reading settings."""
permission_classes = [permissions.IsAuthenticated]
serializer_class = ReadingSettingsSerializer
def get_queryset(self):
return ReadingSettings.objects.filter(user=self.request.user)
def list(self, request: Request) -> Response:
settings, _created = ReadingSettings.objects.get_or_create(
user=request.user,
)
serializer = self.get_serializer(settings)
return Response(serializer.data)
def partial_update(self, request: Request) -> Response:
settings, _created = ReadingSettings.objects.get_or_create(
user=request.user,
)
serializer = self.get_serializer(settings, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main() -> None:
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()
-109
View File
@@ -1,109 +0,0 @@
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "django-insecure-dev-change-me")
DEBUG = os.environ.get("DJANGO_DEBUG", "True").lower() in ("true", "1", "yes")
ALLOWED_HOSTS: list[str] = ["*"]
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# Third-party
"rest_framework",
"rest_framework.authtoken",
"corsheaders",
# Local
"books",
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "project.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "project.wsgi.application"
# ── Database ──────────────────────────────────────────────
_database_url = os.environ.get(
"DATABASE_URL",
"postgres://cloudreader:cloudreader@localhost:5432/cloudreader",
)
_db_parts = _database_url.replace("postgres://", "").split("@")
_credentials, _host_db = _db_parts[0], _db_parts[1]
_db_user, _db_pass = _credentials.split(":")
_host_port, _db_name = _host_db.split("/")
_db_host = _host_port.split(":")[0]
_db_port = _host_port.split(":")[1] if ":" in _host_port else "5432"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": _db_name,
"USER": _db_user,
"PASSWORD": _db_pass,
"HOST": _db_host,
"PORT": _db_port,
}
}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# ── Auth / Password Validation ────────────────────────────
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
]
# ── DRF ───────────────────────────────────────────────────
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.TokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
}
# ── CORS ──────────────────────────────────────────────────
CORS_ALLOW_ALL_ORIGINS = True # dev only
# ── Media / Uploads ───────────────────────────────────────
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
# ── i18n / Static ─────────────────────────────────────────
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
-7
View File
@@ -1,7 +0,0 @@
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("books.urls")),
]
-6
View File
@@ -1,6 +0,0 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")
application = get_wsgi_application()
-21
View File
@@ -1,21 +0,0 @@
[project]
name = "api"
version = "1.0.0"
description = "Cloud Reader API — Book Search and Discovery"
requires-python = ">=3.12"
dependencies = [
"django>=5.1,<6.0",
"djangorestframework>=3.15,<4.0",
"django-cors-headers>=4.4,<5.0",
"django-filter>=24.3,<25.0",
"psycopg2-binary>=2.9,<3.0",
"pydantic>=2.0,<3.0",
"pydantic-settings>=2.0,<3.0",
]
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["books*", "project*"]
Generated
-290
View File
@@ -1,290 +0,0 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "api"
version = "1.0.0"
source = { editable = "." }
dependencies = [
{ name = "django" },
{ name = "django-cors-headers" },
{ name = "django-filter" },
{ name = "djangorestframework" },
{ name = "psycopg2-binary" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
]
[package.metadata]
requires-dist = [
{ name = "django", specifier = ">=5.1,<6.0" },
{ name = "django-cors-headers", specifier = ">=4.4,<5.0" },
{ name = "django-filter", specifier = ">=24.3,<25.0" },
{ name = "djangorestframework", specifier = ">=3.15,<4.0" },
{ name = "psycopg2-binary", specifier = ">=2.9,<3.0" },
{ name = "pydantic", specifier = ">=2.0,<3.0" },
{ name = "pydantic-settings", specifier = ">=2.0,<3.0" },
]
[[package]]
name = "asgiref"
version = "3.11.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
]
[[package]]
name = "django"
version = "5.2.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/65/95/95f7faa0950867afaa0bef2460c6263afd6a2c78cc9434046ed28160b015/django-5.2.14.tar.gz", hash = "sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2", size = 10895118, upload-time = "2026-05-05T13:57:31.104Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/44/f172870cf87aa25afef48fb72adba89ee8b77fcab6f3b23d240b923f1528/django-5.2.14-py3-none-any.whl", hash = "sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76", size = 8311320, upload-time = "2026-05-05T13:57:25.795Z" },
]
[[package]]
name = "django-cors-headers"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" },
]
[[package]]
name = "django-filter"
version = "24.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/bc/dc19ae39c235332926dd0efe0951f663fa1a9fc6be8430737ff7fd566b20/django_filter-24.3.tar.gz", hash = "sha256:d8ccaf6732afd21ca0542f6733b11591030fa98669f8d15599b358e24a2cd9c3", size = 144444, upload-time = "2024-08-02T13:27:58.132Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/09/b1/92f1c30b47c1ebf510c35a2ccad9448f73437e5891bbd2b4febe357cc3de/django_filter-24.3-py3-none-any.whl", hash = "sha256:c4852822928ce17fb699bcfccd644b3574f1a2d80aeb2b4ff4f16b02dd49dc64", size = 95011, upload-time = "2024-08-02T13:27:55.616Z" },
]
[[package]]
name = "djangorestframework"
version = "3.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ca/d7/c016e69fac19ff8afdc89db9d31d9ae43ae031e4d1993b20aca179b8301a/djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5", size = 905742, upload-time = "2026-03-24T16:58:33.705Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844, upload-time = "2026-03-24T16:58:31.845Z" },
]
[[package]]
name = "psycopg2-binary"
version = "2.9.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459, upload-time = "2026-04-20T23:34:30.549Z" },
{ url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936, upload-time = "2026-04-20T23:34:32.77Z" },
{ url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676, upload-time = "2026-04-20T23:34:35.18Z" },
{ url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917, upload-time = "2026-04-20T23:34:37.982Z" },
{ url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843, upload-time = "2026-04-20T23:34:40.856Z" },
{ url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556, upload-time = "2026-04-20T23:34:44.016Z" },
{ url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714, upload-time = "2026-04-20T23:34:46.233Z" },
{ url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154, upload-time = "2026-04-20T23:34:49.528Z" },
{ url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882, upload-time = "2026-04-20T23:34:51.86Z" },
{ url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298, upload-time = "2026-04-20T23:34:54.124Z" },
{ url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230, upload-time = "2026-04-20T23:34:56.242Z" },
{ url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419, upload-time = "2026-04-20T23:34:58.754Z" },
{ url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990, upload-time = "2026-04-20T23:35:00.821Z" },
{ url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696, upload-time = "2026-04-20T23:35:03.409Z" },
{ url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982, upload-time = "2026-04-20T23:35:05.583Z" },
{ url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867, upload-time = "2026-04-20T23:35:07.902Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578, upload-time = "2026-04-20T23:35:10.149Z" },
{ url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816, upload-time = "2026-04-20T23:35:12.56Z" },
{ url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307, upload-time = "2026-04-20T23:35:15.029Z" },
{ url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968, upload-time = "2026-04-20T23:35:16.757Z" },
{ url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369, upload-time = "2026-04-20T23:35:18.933Z" },
{ url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" },
{ url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" },
{ url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" },
{ url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" },
{ url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" },
{ url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" },
{ url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" },
{ url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" },
{ url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" },
{ url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" },
{ url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" },
{ url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "sqlparse"
version = "0.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "tzdata"
version = "2026.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
]
+9 -1
View File
@@ -5,4 +5,12 @@ DB_NAME=cloud_reader
DB_USER=postgres DB_USER=postgres
DB_PASSWORD=postgres DB_PASSWORD=postgres
DB_HOST=localhost DB_HOST=localhost
DB_PORT=5432 DB_PORT=5432
# Open Library metadata enrichment (optional)
OPENLIBRARY_ENABLED=true
OPENLIBRARY_PREFERRED_LANG=es
OPENLIBRARY_FALLBACK_LANG=en
OPENLIBRARY_TIMEOUT_SECONDS=10
OPENLIBRARY_CONNECT_TIMEOUT_SECONDS=15
OPENLIBRARY_USER_AGENT=CloudReader/1.0
+1
View File
@@ -0,0 +1 @@
3.12
+3 -3
View File
@@ -6,10 +6,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev gcc && \ libpq-dev gcc && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
COPY pyproject.toml ./ COPY requirements.txt ./
RUN pip install uv && uv sync --frozen RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . ./
RUN mkdir -p media RUN mkdir -p media
+11 -3
View File
@@ -5,9 +5,17 @@ from apps.annotations.models import Bookmark, Note
@admin.register(Bookmark) @admin.register(Bookmark)
class BookmarkAdmin(admin.ModelAdmin): class BookmarkAdmin(admin.ModelAdmin):
list_display = ("user", "book", "page", "created_at") list_display = (
list_select_related = ("user", "book") "user",
search_fields = ("user__email", "book__title", "location_text") "ebook",
"chapter_index",
"chapter_title",
"page",
"highlight_color",
"created_at",
)
list_select_related = ("user", "ebook")
search_fields = ("user__email", "ebook__title", "location_text", "content")
list_filter = ("created_at",) list_filter = ("created_at",)
@@ -0,0 +1,57 @@
# Generated by Django 5.1.7 on 2026-06-03 22:27
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('books', '0002_ebook_file_size_ebook_format_ebook_metadata_json_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Note',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('page', models.PositiveIntegerField()),
('location_text', models.TextField(blank=True, default='', help_text='The selected passage text this note refers to')),
('content', models.TextField(help_text='The note body content')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='books.book')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Note',
'verbose_name_plural': 'Notes',
'db_table': 'annotations_note',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Bookmark',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('page', models.PositiveIntegerField()),
('location_text', models.TextField(blank=True, default='', help_text='The selected passage text at this location')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to='books.book')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Bookmark',
'verbose_name_plural': 'Bookmarks',
'db_table': 'annotations_bookmark',
'ordering': ['-created_at'],
'constraints': [models.UniqueConstraint(fields=('user', 'book', 'page'), name='uq_bookmark_user_book_page')],
},
),
]
@@ -0,0 +1,93 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
def clear_legacy_bookmarks(apps, schema_editor):
Bookmark = apps.get_model("annotations", "Bookmark")
Bookmark.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
("books", "0003_readingprogress_epub_location"),
("annotations", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RunPython(clear_legacy_bookmarks, migrations.RunPython.noop),
migrations.RemoveConstraint(
model_name="bookmark",
name="uq_bookmark_user_book_page",
),
migrations.RemoveField(
model_name="bookmark",
name="book",
),
migrations.AddField(
model_name="bookmark",
name="ebook",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="bookmarks",
to="books.ebook",
null=True,
),
),
migrations.AddField(
model_name="bookmark",
name="epub_cfi",
field=models.CharField(db_index=True, default="", max_length=2048),
preserve_default=False,
),
migrations.AddField(
model_name="bookmark",
name="chapter_index",
field=models.PositiveIntegerField(db_index=True, default=0),
),
migrations.AddField(
model_name="bookmark",
name="chapter_title",
field=models.CharField(blank=True, default="", max_length=512),
),
migrations.AddField(
model_name="bookmark",
name="content",
field=models.TextField(blank=True, default=""),
),
migrations.AlterField(
model_name="bookmark",
name="page",
field=models.PositiveIntegerField(
default=1,
help_text="Legacy/display page; derived from chapter_index + 1",
),
),
migrations.AlterModelOptions(
name="bookmark",
options={
"ordering": ["chapter_index", "epub_cfi"],
"verbose_name": "Bookmark",
"verbose_name_plural": "Bookmarks",
},
),
# ebook was added nullable for SQLite; enforce NOT NULL via AlterField
migrations.AlterField(
model_name="bookmark",
name="ebook",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="bookmarks",
to="books.ebook",
),
),
migrations.AddConstraint(
model_name="bookmark",
constraint=models.UniqueConstraint(
fields=("user", "ebook", "epub_cfi"),
name="uq_bookmark_user_ebook_cfi",
),
),
]
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("annotations", "0002_bookmark_ebook_epub_fields"),
]
operations = [
migrations.AddField(
model_name="bookmark",
name="highlight_color",
field=models.CharField(default="#fde047", max_length=7),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.1.7 on 2026-06-04 03:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('annotations', '0003_bookmark_highlight_color'),
]
operations = [
migrations.AlterField(
model_name='bookmark',
name='content',
field=models.TextField(blank=True, default='', help_text='Optional user thought; empty means bookmark-only'),
),
migrations.AlterField(
model_name='bookmark',
name='highlight_color',
field=models.CharField(default='#fde047', help_text='Hex color for in-book passage highlight (e.g. #fde047)', max_length=7),
),
]
+26 -10
View File
@@ -5,7 +5,7 @@ from django.db import models
class Bookmark(models.Model): class Bookmark(models.Model):
"""A saved location in a book that the user can return to.""" """A saved passage anchor in an uploaded ebook (EPUB CFI + optional thought)."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey( user = models.ForeignKey(
@@ -14,18 +14,34 @@ class Bookmark(models.Model):
related_name="bookmarks", related_name="bookmarks",
db_index=True, db_index=True,
) )
book = models.ForeignKey( ebook = models.ForeignKey(
"books.Book", "books.EBook",
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="bookmarks", related_name="bookmarks",
db_index=True, db_index=True,
) )
page = models.PositiveIntegerField() epub_cfi = models.CharField(max_length=2048, db_index=True)
chapter_index = models.PositiveIntegerField(default=0, db_index=True)
chapter_title = models.CharField(max_length=512, blank=True, default="")
page = models.PositiveIntegerField(
default=1,
help_text="Legacy/display page; derived from chapter_index + 1",
)
location_text = models.TextField( location_text = models.TextField(
blank=True, blank=True,
default="", default="",
help_text="The selected passage text at this location", help_text="The selected passage text at this location",
) )
content = models.TextField(
blank=True,
default="",
help_text="Optional user thought; empty means bookmark-only",
)
highlight_color = models.CharField(
max_length=7,
default="#fde047",
help_text="Hex color for in-book passage highlight (e.g. #fde047)",
)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -33,20 +49,20 @@ class Bookmark(models.Model):
db_table = "annotations_bookmark" db_table = "annotations_bookmark"
verbose_name = "Bookmark" verbose_name = "Bookmark"
verbose_name_plural = "Bookmarks" verbose_name_plural = "Bookmarks"
ordering = ["-created_at"] ordering = ["chapter_index", "epub_cfi"]
constraints = [ constraints = [
models.UniqueConstraint( models.UniqueConstraint(
fields=["user", "book", "page"], fields=["user", "ebook", "epub_cfi"],
name="uq_bookmark_user_book_page", name="uq_bookmark_user_ebook_cfi",
) )
] ]
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.user} @ {self.book} p.{self.page}" return f"{self.user} @ {self.ebook} ch.{self.chapter_index}"
class Note(models.Model): class Note(models.Model):
"""A user-written note attached to a specific location in a book.""" """Legacy note model; new UX uses Bookmark.content instead."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey( user = models.ForeignKey(
@@ -81,4 +97,4 @@ class Note(models.Model):
def __str__(self) -> str: def __str__(self) -> str:
preview = self.content[:50] preview = self.content[:50]
return f"{self.user} @ {self.book} p.{self.page}: {preview}" return f"{self.user} @ {self.book} p.{self.page}: {preview}"
+48 -32
View File
@@ -1,62 +1,86 @@
import re
from rest_framework import serializers from rest_framework import serializers
from apps.annotations.models import Bookmark, Note from apps.annotations.models import Bookmark, Note
_HEX_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
def validate_highlight_color(value: str) -> str:
stripped = (value or "").strip()
if not _HEX_COLOR_RE.match(stripped):
raise serializers.ValidationError("highlight_color must be a hex color like #fde047.")
return stripped.lower()
class BookmarkSerializer(serializers.ModelSerializer): class BookmarkSerializer(serializers.ModelSerializer):
"""Serialize Bookmark data with full details.""" ebook_title = serializers.CharField(source="ebook.title", read_only=True)
book_title = serializers.CharField(source="book.title", read_only=True)
class Meta: class Meta:
model = Bookmark model = Bookmark
fields = [ fields = [
"id", "id",
"book", "ebook",
"book_title", "ebook_title",
"epub_cfi",
"chapter_index",
"chapter_title",
"page", "page",
"location_text", "location_text",
"content",
"highlight_color",
"created_at", "created_at",
"updated_at", "updated_at",
] ]
read_only_fields = ["id", "created_at", "updated_at", "book_title"] read_only_fields = ["id", "created_at", "updated_at", "ebook_title", "page"]
def validate_page(self, value: int) -> int:
if value < 1:
raise serializers.ValidationError("Page must be a positive integer.")
return value
class BookmarkCreateSerializer(serializers.ModelSerializer): class BookmarkCreateSerializer(serializers.ModelSerializer):
"""Serializer used for creating bookmarks. Sets user from request context."""
class Meta: class Meta:
model = Bookmark model = Bookmark
fields = ["book", "page", "location_text"] fields = [
"ebook",
"epub_cfi",
"chapter_index",
"chapter_title",
"location_text",
"content",
"highlight_color",
]
def validate_page(self, value: int) -> int: def validate_highlight_color(self, value: str) -> str:
if value < 1: return validate_highlight_color(value)
raise serializers.ValidationError("Page must be a positive integer.")
def validate_epub_cfi(self, value: str) -> str:
stripped = (value or "").strip()
if not stripped:
raise serializers.ValidationError("epub_cfi is required.")
return stripped
def validate_chapter_index(self, value: int) -> int:
if value < 0:
raise serializers.ValidationError("chapter_index must be non-negative.")
return value return value
def validate(self, attrs): def validate(self, attrs):
user = self.context["request"].user user = self.context["request"].user
if Bookmark.objects.filter( ebook = attrs["ebook"]
user=user, book=attrs["book"], page=attrs["page"] epub_cfi = attrs["epub_cfi"]
).exists(): if Bookmark.objects.filter(user=user, ebook=ebook, epub_cfi=epub_cfi).exists():
raise serializers.ValidationError( raise serializers.ValidationError(
{"page": "A bookmark already exists at this page for this book."} {"epub_cfi": "A marker already exists for this passage."}
) )
return attrs return attrs
def create(self, validated_data): def create(self, validated_data):
validated_data["user"] = self.context["request"].user validated_data["user"] = self.context["request"].user
validated_data["page"] = validated_data.get("chapter_index", 0) + 1
validated_data["content"] = (validated_data.get("content") or "").strip()
return super().create(validated_data) return super().create(validated_data)
class NoteSerializer(serializers.ModelSerializer): class NoteSerializer(serializers.ModelSerializer):
"""Serialize Note data with full details."""
book_title = serializers.CharField(source="book.title", read_only=True) book_title = serializers.CharField(source="book.title", read_only=True)
class Meta: class Meta:
@@ -78,16 +102,8 @@ class NoteSerializer(serializers.ModelSerializer):
raise serializers.ValidationError("Page must be a positive integer.") raise serializers.ValidationError("Page must be a positive integer.")
return value return value
def validate_content(self, value: str) -> str:
stripped = value.strip()
if not stripped:
raise serializers.ValidationError("Note content cannot be empty.")
return stripped
class NoteCreateSerializer(serializers.ModelSerializer): class NoteCreateSerializer(serializers.ModelSerializer):
"""Serializer used for creating notes. Sets user from request context."""
class Meta: class Meta:
model = Note model = Note
fields = ["book", "page", "location_text", "content"] fields = ["book", "page", "location_text", "content"]
@@ -105,4 +121,4 @@ class NoteCreateSerializer(serializers.ModelSerializer):
def create(self, validated_data): def create(self, validated_data):
validated_data["user"] = self.context["request"].user validated_data["user"] = self.context["request"].user
return super().create(validated_data) return super().create(validated_data)
+66 -231
View File
@@ -6,14 +6,10 @@ from rest_framework import status
from rest_framework.test import APIClient from rest_framework.test import APIClient
from apps.annotations.models import Bookmark, Note from apps.annotations.models import Bookmark, Note
from apps.books.models import Book from apps.books.models import Book, EBook
from apps.users.models import User from apps.users.models import User
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture @pytest.fixture
def api_client() -> APIClient: def api_client() -> APIClient:
return APIClient() return APIClient()
@@ -53,12 +49,26 @@ def book() -> Book:
@pytest.fixture @pytest.fixture
def bookmark(auth_client, user: User, book: Book) -> Bookmark: def ebook(user: User) -> EBook:
return EBook.objects.create(
user=user,
title="Test Ebook",
author="Test Author",
format="epub",
)
@pytest.fixture
def bookmark(auth_client, user: User, ebook: EBook) -> Bookmark:
return Bookmark.objects.create( return Bookmark.objects.create(
user=user, user=user,
book=book, ebook=ebook,
page=42, epub_cfi="epubcfi(/6/4!/4/2,/1:0,/1:10)",
chapter_index=2,
chapter_title="Chapter 3",
page=3,
location_text="important passage", location_text="important passage",
content="",
) )
@@ -73,259 +83,84 @@ def note(auth_client, user: User, book: Book) -> Note:
) )
# ---------------------------------------------------------------------------
# Bookmark tests
# ---------------------------------------------------------------------------
class TestBookmarkList: class TestBookmarkList:
url = reverse("bookmark-list") url = reverse("bookmark-list")
def test_unauthenticated_user_cannot_list(self, api_client: APIClient): def test_list_requires_auth(self, api_client: APIClient):
response = api_client.get(self.url) response = api_client.get(self.url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_list_returns_user_bookmarks_only( def test_list_returns_user_bookmarks_only(
self, auth_client: APIClient, user: User, other_user: User, book: Book self, auth_client: APIClient, user: User, other_user: User, ebook: EBook
): ):
Bookmark.objects.create(user=user, book=book, page=1) Bookmark.objects.create(
Bookmark.objects.create(user=other_user, book=book, page=2) user=user,
ebook=ebook,
epub_cfi="epubcfi(/6/4!/4/2,/1:0,/1:5)",
chapter_index=0,
page=1,
)
other_ebook = EBook.objects.create(user=other_user, title="Other", format="epub")
Bookmark.objects.create(
user=other_user,
ebook=other_ebook,
epub_cfi="epubcfi(/6/4!/4/2,/2:0,/2:5)",
chapter_index=0,
page=1,
)
response = auth_client.get(self.url) response = auth_client.get(self.url)
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
results = response.data["results"] results = response.data["results"]
assert len(results) == 1 assert len(results) == 1
assert results[0]["page"] == 1
def test_list_returns_empty_when_no_bookmarks( def test_filter_by_ebook(self, auth_client: APIClient, bookmark: Bookmark, ebook: EBook):
self, auth_client: APIClient response = auth_client.get(self.url, {"ebook": str(ebook.id)})
):
response = auth_client.get(self.url)
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
assert response.data["count"] == 0 assert len(response.data["results"]) == 1
def test_list_orders_by_newest_first(
self, auth_client: APIClient, user: User, book: Book
):
b1 = Bookmark.objects.create(user=user, book=book, page=1)
b2 = Bookmark.objects.create(user=user, book=book, page=2)
response = auth_client.get(self.url)
results = response.data["results"]
assert results[0]["page"] == 2
assert results[1]["page"] == 1
def test_list_includes_book_title(
self, auth_client: APIClient, bookmark: Bookmark
):
response = auth_client.get(self.url)
assert response.status_code == status.HTTP_200_OK
assert response.data["results"][0]["book_title"] == "Test Book"
class TestBookmarkCreate: class TestBookmarkCreate:
url = reverse("bookmark-list") url = reverse("bookmark-list")
def test_create_bookmark(self, auth_client: APIClient, book: Book): def test_create_marker(self, auth_client: APIClient, ebook: EBook):
data = {"book": str(book.id), "page": 10, "location_text": "key insight"} data = {
"ebook": ebook.id,
"epub_cfi": "epubcfi(/6/4!/4/2,/1:0,/1:20)",
"chapter_index": 1,
"chapter_title": "Chapter 2",
"location_text": "Selected text",
"content": "My thought",
}
response = auth_client.post(self.url, data, format="json") response = auth_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_201_CREATED assert response.status_code == status.HTTP_201_CREATED
assert response.data["page"] == 10 assert response.data["content"] == "My thought"
assert response.data["ebook"] == ebook.id
def test_create_bookmark_without_location_text( def test_create_bookmark_only_empty_content(self, auth_client: APIClient, ebook: EBook):
self, auth_client: APIClient, book: Book data = {
): "ebook": ebook.id,
data = {"book": str(book.id), "page": 5} "epub_cfi": "epubcfi(/6/4!/4/2,/3:0,/3:8)",
"chapter_index": 0,
"location_text": "Quote only",
"content": "",
}
response = auth_client.post(self.url, data, format="json") response = auth_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_201_CREATED assert response.status_code == status.HTTP_201_CREATED
assert response.data["page"] == 5 assert response.data["content"] == ""
def test_duplicate_bookmark_page_is_rejected( def test_duplicate_cfi_rejected(self, auth_client: APIClient, bookmark: Bookmark, ebook: EBook):
self, auth_client: APIClient, bookmark: Bookmark data = {
): "ebook": ebook.id,
data = {"book": str(bookmark.book.id), "page": bookmark.page} "epub_cfi": bookmark.epub_cfi,
response = auth_client.post(self.url, data, format="json") "chapter_index": 0,
assert response.status_code == status.HTTP_400_BAD_REQUEST "location_text": "dup",
}
def test_unauthenticated_user_cannot_create(
self, api_client: APIClient, book: Book
):
data = {"book": str(book.id), "page": 10}
response = api_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_invalid_page_rejected(
self, auth_client: APIClient, book: Book
):
data = {"book": str(book.id), "page": 0}
response = auth_client.post(self.url, data, format="json") response = auth_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.status_code == status.HTTP_400_BAD_REQUEST
class TestBookmarkDetail: class TestBookmarkDetail:
def test_get_bookmark( def test_delete_bookmark(self, auth_client: APIClient, bookmark: Bookmark):
self, auth_client: APIClient, bookmark: Bookmark
):
url = reverse("bookmark-detail", args=[str(bookmark.id)])
response = auth_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert response.data["page"] == bookmark.page
def test_cannot_access_other_users_bookmark(
self, api_client: APIClient, other_user: User, bookmark: Bookmark
):
api_client.force_authenticate(user=other_user)
url = reverse("bookmark-detail", args=[str(bookmark.id)])
response = api_client.get(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
class TestBookmarkDelete:
def test_delete_bookmark(
self, auth_client: APIClient, bookmark: Bookmark
):
url = reverse("bookmark-detail", args=[str(bookmark.id)]) url = reverse("bookmark-detail", args=[str(bookmark.id)])
response = auth_client.delete(url) response = auth_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT assert response.status_code == status.HTTP_204_NO_CONTENT
assert Bookmark.objects.count() == 0
def test_cannot_delete_other_users_bookmark(
self, api_client: APIClient, other_user: User, bookmark: Bookmark
):
api_client.force_authenticate(user=other_user)
url = reverse("bookmark-detail", args=[str(bookmark.id)])
response = api_client.delete(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
class TestBookmarkFilterByBook:
def test_filter_by_book(
self, auth_client: APIClient, user: User, book: Book
):
other_book = Book.objects.create(title="Other", author="Other")
Bookmark.objects.create(user=user, book=book, page=1)
Bookmark.objects.create(user=user, book=other_book, page=2)
url = reverse("bookmark-list")
response = auth_client.get(url, {"book": str(book.id)})
assert response.status_code == status.HTTP_200_OK
assert response.data["count"] == 1
assert response.data["results"][0]["page"] == 1
# ---------------------------------------------------------------------------
# Note tests
# ---------------------------------------------------------------------------
class TestNoteList:
url = reverse("note-list")
def test_unauthenticated_user_cannot_list(self, api_client: APIClient):
response = api_client.get(self.url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_list_returns_user_notes_only(
self, auth_client: APIClient, user: User, other_user: User, book: Book
):
Note.objects.create(user=user, book=book, page=1, content="My note")
Note.objects.create(user=other_user, book=book, page=2, content="Other's note")
response = auth_client.get(self.url)
assert response.status_code == status.HTTP_200_OK
results = response.data["results"]
assert len(results) == 1
assert results[0]["content"] == "My note"
def test_list_includes_book_title(
self, auth_client: APIClient, note: Note
):
response = auth_client.get(self.url)
assert response.status_code == status.HTTP_200_OK
assert response.data["results"][0]["book_title"] == "Test Book"
class TestNoteCreate:
url = reverse("note-list")
def test_create_note(self, auth_client: APIClient, book: Book):
data = {
"book": str(book.id),
"page": 20,
"location_text": "interesting part",
"content": "This is a thoughtful note.",
}
response = auth_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_201_CREATED
assert response.data["content"] == "This is a thoughtful note."
def test_create_note_without_location_text(
self, auth_client: APIClient, book: Book
):
data = {"book": str(book.id), "page": 20, "content": "A note."}
response = auth_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_201_CREATED
def test_empty_content_rejected(
self, auth_client: APIClient, book: Book
):
data = {"book": str(book.id), "page": 20, "content": " "}
response = auth_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_unauthenticated_user_cannot_create(
self, api_client: APIClient, book: Book
):
data = {"book": str(book.id), "page": 20, "content": "Note"}
response = api_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
class TestNoteUpdate:
def test_update_note_content(
self, auth_client: APIClient, note: Note
):
url = reverse("note-detail", args=[str(note.id)])
data = {"content": "Updated note content."}
response = auth_client.patch(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data["content"] == "Updated note content."
def test_cannot_update_other_users_note(
self, api_client: APIClient, other_user: User, note: Note
):
api_client.force_authenticate(user=other_user)
url = reverse("note-detail", args=[str(note.id)])
data = {"content": "Hacked!"}
response = api_client.patch(url, data, format="json")
assert response.status_code == status.HTTP_404_NOT_FOUND
class TestNoteDelete:
def test_delete_note(self, auth_client: APIClient, note: Note):
url = reverse("note-detail", args=[str(note.id)])
response = auth_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert Note.objects.count() == 0
def test_batch_delete_notes(
self, auth_client: APIClient, user: User, book: Book
):
n1 = Note.objects.create(user=user, book=book, page=1, content="A")
n2 = Note.objects.create(user=user, book=book, page=2, content="B")
url = reverse("note-batch-delete")
response = auth_client.delete(url, {"ids": [str(n1.id), str(n2.id)]}, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data["deleted"] == 2
class TestNoteFilterByBook:
def test_filter_by_book(
self, auth_client: APIClient, user: User, book: Book
):
other_book = Book.objects.create(title="Other", author="Other")
Note.objects.create(user=user, book=book, page=1, content="In book")
Note.objects.create(user=user, book=other_book, page=2, content="In other")
url = reverse("note-list")
response = auth_client.get(url, {"book": str(book.id)})
assert response.status_code == status.HTTP_200_OK
assert response.data["count"] == 1
assert response.data["results"][0]["content"] == "In book"
+10 -20
View File
@@ -16,14 +16,14 @@ from apps.annotations.serializers import (
class BookmarkViewSet(viewsets.ModelViewSet): class BookmarkViewSet(viewsets.ModelViewSet):
"""CRUD for user bookmarks. Users can only manage their own bookmarks.""" """CRUD for user ebook markers (passage anchors + optional thoughts)."""
permission_classes = [IsAuthenticated, IsOwner] permission_classes = [IsAuthenticated, IsOwner]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_fields = ["book"] filterset_fields = ["ebook"]
search_fields = ["location_text"] search_fields = ["location_text", "content", "chapter_title"]
ordering_fields = ["created_at", "page"] ordering_fields = ["chapter_index", "epub_cfi", "created_at"]
ordering = ["-created_at"] ordering = ["chapter_index", "epub_cfi"]
def get_serializer_class(self): def get_serializer_class(self):
if self.action == "create": if self.action == "create":
@@ -31,16 +31,13 @@ class BookmarkViewSet(viewsets.ModelViewSet):
return BookmarkSerializer return BookmarkSerializer
def get_queryset(self): def get_queryset(self):
return Bookmark.objects.filter(user=self.request.user).select_related( return Bookmark.objects.filter(user=self.request.user).select_related("ebook")
"book"
)
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(user=self.request.user) serializer.save(user=self.request.user)
@action(detail=False, methods=["delete"], url_path="batch-delete") @action(detail=False, methods=["delete"], url_path="batch-delete")
def batch_delete(self, request): def batch_delete(self, request):
"""Delete multiple bookmarks by id list."""
ids = request.data.get("ids", []) ids = request.data.get("ids", [])
if not ids: if not ids:
return Response( return Response(
@@ -49,13 +46,11 @@ class BookmarkViewSet(viewsets.ModelViewSet):
deleted, _ = Bookmark.objects.filter( deleted, _ = Bookmark.objects.filter(
id__in=ids, user=request.user id__in=ids, user=request.user
).delete() ).delete()
return Response( return Response({"deleted": deleted}, status=status.HTTP_200_OK)
{"deleted": deleted}, status=status.HTTP_200_OK
)
class NoteViewSet(viewsets.ModelViewSet): class NoteViewSet(viewsets.ModelViewSet):
"""CRUD for user notes. Users can only manage their own notes.""" """Legacy notes API (catalog Book FK)."""
permission_classes = [IsAuthenticated, IsOwner] permission_classes = [IsAuthenticated, IsOwner]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
@@ -70,16 +65,13 @@ class NoteViewSet(viewsets.ModelViewSet):
return NoteSerializer return NoteSerializer
def get_queryset(self): def get_queryset(self):
return Note.objects.filter(user=self.request.user).select_related( return Note.objects.filter(user=self.request.user).select_related("book")
"book"
)
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(user=self.request.user) serializer.save(user=self.request.user)
@action(detail=False, methods=["delete"], url_path="batch-delete") @action(detail=False, methods=["delete"], url_path="batch-delete")
def batch_delete(self, request): def batch_delete(self, request):
"""Delete multiple notes by id list."""
ids = request.data.get("ids", []) ids = request.data.get("ids", [])
if not ids: if not ids:
return Response( return Response(
@@ -88,6 +80,4 @@ class NoteViewSet(viewsets.ModelViewSet):
deleted, _ = Note.objects.filter( deleted, _ = Note.objects.filter(
id__in=ids, user=request.user id__in=ids, user=request.user
).delete() ).delete()
return Response( return Response({"deleted": deleted}, status=status.HTTP_200_OK)
{"deleted": deleted}, status=status.HTTP_200_OK
)
@@ -0,0 +1,96 @@
# Generated by Django 5.1.7 on 2026-05-26 03:33
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Book',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(db_index=True, max_length=512)),
('author', models.CharField(blank=True, db_index=True, default='', max_length=256)),
('genre', models.CharField(blank=True, db_index=True, default='', max_length=128)),
('description', models.TextField(blank=True, default='')),
('reading_status', models.CharField(choices=[('want_to_read', 'Want to Read'), ('reading', 'Reading'), ('finished', 'Finished'), ('dnf', 'Did Not Finish')], db_index=True, default='want_to_read', max_length=20)),
('total_pages', models.PositiveIntegerField(default=0)),
('cover_image', models.URLField(blank=True, default='')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Book',
'verbose_name_plural': 'Books',
'db_table': 'books_book',
'ordering': ['title'],
'indexes': [models.Index(fields=['title', 'author', 'genre'], name='books_book_title_9fddc2_idx')],
},
),
migrations.CreateModel(
name='EBook',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=512)),
('author', models.CharField(blank=True, default='', max_length=256)),
('file', models.FileField(upload_to='ebooks/%Y/%m/%d/')),
('cover_image', models.ImageField(blank=True, null=True, upload_to='ebook_covers/%Y/%m/%d/')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ebooks', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'E-Book',
'verbose_name_plural': 'E-Books',
'db_table': 'books_ebook',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ReadingProgress',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('current_position', models.FloatField(default=0.0)),
('last_page', models.IntegerField(default=0)),
('updated_at', models.DateTimeField(auto_now=True)),
('ebook', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='reading_progress', to='books.ebook')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reading_progress', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name_plural': 'reading progress',
'db_table': 'books_reading_progress',
},
),
migrations.CreateModel(
name='ReadingSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('font_size', models.IntegerField(default=18)),
('font_style', models.CharField(choices=[('sans-serif', 'Sans Serif'), ('serif', 'Serif'), ('monospace', 'Monospace')], default='sans-serif', max_length=20)),
('background_color', models.CharField(choices=[('#ffffff', 'White'), ('#f4e4c1', 'Sepia'), ('#1a1a2e', 'Dark'), ('#c7edcc', 'Green')], default='#ffffff', max_length=7)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='reading_settings', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name_plural': 'reading settings',
'db_table': 'books_reading_settings',
},
),
migrations.AddIndex(
model_name='ebook',
index=models.Index(fields=['user', '-created_at'], name='books_ebook_user_id_0b6bdb_idx'),
),
migrations.AlterUniqueTogether(
name='readingprogress',
unique_together={('user', 'ebook')},
),
]
@@ -0,0 +1,105 @@
# Generated by Django 5.1.7 on 2026-06-03 22:21
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('books', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='ebook',
name='file_size',
field=models.BigIntegerField(default=0),
),
migrations.AddField(
model_name='ebook',
name='format',
field=models.CharField(blank=True, default='', editable=False, max_length=20),
),
migrations.AddField(
model_name='ebook',
name='metadata_json',
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name='ebook',
name='page_count',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='readingprogress',
name='device_id',
field=models.CharField(blank=True, default='', max_length=128),
),
migrations.AddField(
model_name='readingprogress',
name='device_name',
field=models.CharField(blank=True, default='', max_length=128),
),
migrations.AddField(
model_name='readingprogress',
name='version',
field=models.PositiveIntegerField(default=1),
),
migrations.AlterField(
model_name='ebook',
name='author',
field=models.CharField(blank=True, db_index=True, default='', max_length=256),
),
migrations.AlterField(
model_name='ebook',
name='title',
field=models.CharField(db_index=True, max_length=512),
),
migrations.CreateModel(
name='BookChapter',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=512)),
('index', models.IntegerField(default=0)),
('href', models.CharField(blank=True, default='', max_length=1024)),
('children', models.JSONField(blank=True, default=list)),
('ebook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chapters', to='books.ebook')),
],
options={
'verbose_name': 'Book Chapter',
'verbose_name_plural': 'Book Chapters',
'db_table': 'books_book_chapter',
'ordering': ['index'],
},
),
migrations.CreateModel(
name='DownloadRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file_size', models.BigIntegerField(default=0)),
('downloaded_at', models.DateTimeField(auto_now_add=True)),
('ebook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='download_records', to='books.ebook')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='download_records', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Download Record',
'verbose_name_plural': 'Download Records',
'db_table': 'books_download_record',
'ordering': ['-downloaded_at'],
},
),
migrations.DeleteModel(
name='ReadingSettings',
),
migrations.AddIndex(
model_name='bookchapter',
index=models.Index(fields=['ebook', 'index'], name='books_book__ebook_i_464cd6_idx'),
),
migrations.AlterUniqueTogether(
name='downloadrecord',
unique_together={('user', 'ebook')},
),
]
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("books", "0002_ebook_file_size_ebook_format_ebook_metadata_json_and_more"),
]
operations = [
migrations.AddField(
model_name="readingprogress",
name="epub_location",
field=models.CharField(blank=True, default="", max_length=2048),
),
]
+141 -6
View File
@@ -1,11 +1,23 @@
from django.conf import settings
from django.db import models from django.db import models
from django.db.models.signals import post_delete
from django.dispatch import receiver
from pathlib import Path
class ReadingStatus(models.TextChoices):
WANT_TO_READ = "want_to_read", "Want to Read"
READING = "reading", "Reading"
FINISHED = "finished", "Finished"
DNF = "dnf", "Did Not Finish"
class Book(models.Model): class Book(models.Model):
"""Represents a book in the user's library.""" title = models.CharField(max_length=512, db_index=True)
author = models.CharField(max_length=256, blank=True, default="", db_index=True)
title = models.CharField(max_length=512) genre = models.CharField(max_length=128, blank=True, default="", db_index=True)
author = models.CharField(max_length=256, blank=True, default="") description = models.TextField(blank=True, default="")
reading_status = models.CharField(max_length=20, choices=ReadingStatus.choices, default=ReadingStatus.WANT_TO_READ, db_index=True)
total_pages = models.PositiveIntegerField(default=0) total_pages = models.PositiveIntegerField(default=0)
cover_image = models.URLField(blank=True, default="") cover_image = models.URLField(blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
@@ -16,6 +28,129 @@ class Book(models.Model):
verbose_name = "Book" verbose_name = "Book"
verbose_name_plural = "Books" verbose_name_plural = "Books"
ordering = ["title"] ordering = ["title"]
indexes = [models.Index(fields=["title", "author", "genre"])]
def __str__(self) -> str: def __str__(self):
return self.title return self.title
class EBook(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ebooks")
title = models.CharField(max_length=512, db_index=True)
author = models.CharField(max_length=256, blank=True, default="", db_index=True)
format = models.CharField(max_length=20, blank=True, default="", editable=False)
page_count = models.PositiveIntegerField(default=0)
file_size = models.BigIntegerField(default=0)
metadata_json = models.JSONField(blank=True, default=dict)
file = models.FileField(upload_to="ebooks/%Y/%m/%d/")
cover_image = models.ImageField(upload_to="ebook_covers/%Y/%m/%d/", blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "books_ebook"
verbose_name = "E-Book"
verbose_name_plural = "E-Books"
ordering = ["-created_at"]
indexes = [models.Index(fields=["user", "-created_at"])]
def __str__(self):
return self.title
def filename(self):
return Path(self.file.name).name if self.file else ""
class BookChapter(models.Model):
ebook = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name="chapters")
title = models.CharField(max_length=512)
index = models.IntegerField(default=0)
href = models.CharField(max_length=1024, blank=True, default="")
children = models.JSONField(blank=True, default=list)
class Meta:
db_table = "books_book_chapter"
verbose_name = "Book Chapter"
verbose_name_plural = "Book Chapters"
ordering = ["index"]
indexes = [models.Index(fields=["ebook", "index"])]
def __str__(self):
return f"{self.ebook.title} - {self.title}"
@receiver(post_delete, sender=EBook)
def _auto_delete_ebook_file(sender, instance, **kwargs):
if instance.file:
instance.file.delete(save=False)
if instance.cover_image:
instance.cover_image.delete(save=False)
class DownloadRecord(models.Model):
"""Tracks book downloads for offline access management."""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="download_records")
ebook = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name="download_records")
file_size = models.BigIntegerField(default=0)
downloaded_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "books_download_record"
verbose_name = "Download Record"
verbose_name_plural = "Download Records"
ordering = ["-downloaded_at"]
unique_together = [("user", "ebook")]
def __str__(self):
return f"{self.user} - {self.ebook.title}"
class ReadingProgress(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reading_progress")
ebook = models.OneToOneField(EBook, on_delete=models.CASCADE, related_name="reading_progress")
current_position = models.FloatField(default=0.0)
last_page = models.IntegerField(default=0)
epub_location = models.CharField(max_length=2048, blank=True, default="")
device_id = models.CharField(max_length=128, blank=True, default="")
device_name = models.CharField(max_length=128, blank=True, default="")
version = models.PositiveIntegerField(default=1)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "books_reading_progress"
verbose_name_plural = "reading progress"
unique_together = [("user", "ebook")]
def __str__(self):
return f"{self.ebook.title} - {self.current_position:.1f}%"
def update_with_sync(self, position: float, last_page: int,
device_id: str, device_name: str,
client_updated_at: str | None = None) -> tuple["ReadingProgress", bool]:
"""Update progress with conflict resolution (last-write-wins by timestamp).
Returns (instance, applied) where applied is True if the update was applied.
"""
if client_updated_at and self.updated_at:
try:
from django.utils.timezone import is_naive, make_aware
from datetime import datetime
client_dt = datetime.fromisoformat(client_updated_at.replace("Z", "+00:00"))
if is_naive(client_dt):
client_dt = make_aware(client_dt)
if client_dt <= self.updated_at:
return self, False
except (ValueError, TypeError):
pass
self.current_position = position
self.last_page = last_page
self.device_id = device_id
self.device_name = device_name
self.version += 1
self.save(update_fields=[
"current_position", "last_page",
"device_id", "device_name", "version", "updated_at",
])
return self, True
+219 -16
View File
@@ -1,28 +1,231 @@
from rest_framework import serializers from rest_framework import serializers
from apps.books.models import Book import logging
from apps.books.models import Book, BookChapter, EBook, ReadingProgress, ReadingStatus, DownloadRecord
from apps.books.services.ebook_metadata import subjects_from_ebook
from apps.books.services.metadata import enrich_ebook_metadata
from apps.books.services.process_ebook import apply_processing_to_ebook
from apps.reader.models import ReadingSettings
class BookSerializer(serializers.ModelSerializer): logger = logging.getLogger(__name__)
"""Serialize Book data."""
class BookReadingSettingsSerializer(serializers.ModelSerializer):
font_style = serializers.CharField(source="font_family")
class Meta: class Meta:
model = Book model = ReadingSettings
fields = [ fields = ["font_size", "font_style", "background_color"]
"id",
"title", def validate_font_size(self, value: int) -> int:
"author", if value < 12 or value > 36:
"total_pages", raise serializers.ValidationError("Font size must be between 12 and 36.")
"cover_image", return value
"created_at",
"updated_at",
] class BookChapterSerializer(serializers.ModelSerializer):
read_only_fields = ["id", "created_at", "updated_at"] class Meta:
model = BookChapter
fields = ["id", "title", "index", "href", "children"]
class EBookContentSerializer(serializers.Serializer):
page = serializers.IntegerField()
total_pages = serializers.IntegerField()
content = serializers.CharField()
chapter_title = serializers.CharField()
format = serializers.CharField()
class EBookTocSerializer(serializers.Serializer):
chapters = serializers.ListField(child=BookChapterSerializer())
format = serializers.CharField()
page_count = serializers.IntegerField()
class BookListSerializer(serializers.ModelSerializer): class BookListSerializer(serializers.ModelSerializer):
"""Lightweight serializer for list views (excludes heavy fields).""" reading_status_display = serializers.CharField(source="get_reading_status_display", read_only=True)
class Meta: class Meta:
model = Book model = Book
fields = ["id", "title", "author", "total_pages", "cover_image"] fields = ["id", "title", "author", "genre", "reading_status", "reading_status_display", "cover_image"]
class BookDetailSerializer(serializers.ModelSerializer):
reading_status_display = serializers.CharField(source="get_reading_status_display", read_only=True)
class Meta:
model = Book
fields = ["id", "title", "author", "genre", "description", "reading_status", "reading_status_display", "cover_image", "total_pages", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
class BookSerializer(serializers.ModelSerializer):
class Meta:
model = Book
fields = ["id", "title", "author", "genre", "description", "reading_status", "cover_image", "total_pages", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
class EBookListSerializer(serializers.ModelSerializer):
filename = serializers.CharField(read_only=True)
format = serializers.CharField(read_only=True)
progress = serializers.SerializerMethodField()
started = serializers.SerializerMethodField()
subjects = serializers.SerializerMethodField()
class Meta:
model = EBook
fields = [
"id", "title", "author", "filename", "format", "page_count", "file_size",
"cover_image", "created_at", "progress", "started", "subjects",
]
def get_subjects(self, obj: EBook) -> list[str]:
return subjects_from_ebook(obj)
def get_progress(self, obj):
try:
return obj.reading_progress.current_position
except ReadingProgress.DoesNotExist:
return None
def get_started(self, obj):
"""True when the user has opened the reader with saved progress."""
try:
rp = obj.reading_progress
except ReadingProgress.DoesNotExist:
return False
if rp.current_position >= 99:
return False
if obj.format == "pdf":
return rp.last_page > 0 or rp.current_position > 0
return bool((rp.epub_location or "").strip())
class EBookDetailSerializer(serializers.ModelSerializer):
filename = serializers.CharField(read_only=True)
format = serializers.CharField(read_only=True)
file_url = serializers.SerializerMethodField()
progress = serializers.SerializerMethodField()
metadata = serializers.JSONField(source="metadata_json", read_only=True)
class Meta:
model = EBook
fields = [
"id", "title", "author", "filename", "format", "page_count", "file_size",
"file_url", "cover_image", "metadata", "created_at", "updated_at", "progress",
]
def get_file_url(self, obj):
request = self.context.get("request")
if request and obj.file:
return request.build_absolute_uri(obj.file.url)
return ""
def get_progress(self, obj):
try:
rp = obj.reading_progress
return {
"current_position": rp.current_position,
"last_page": rp.last_page,
"epub_location": rp.epub_location,
}
except ReadingProgress.DoesNotExist:
return None
class EBookUploadSerializer(serializers.ModelSerializer):
class Meta:
model = EBook
fields = ["title", "author", "file", "cover_image"]
extra_kwargs = {"title": {"required": True}, "file": {"required": True}}
def validate_file(self, value):
import os
if value is None:
return value
ext = os.path.splitext(str(getattr(value, "name", "")))[1].lower()
if ext not in (".epub", ".pdf"):
raise serializers.ValidationError("Only EPUB and PDF files are supported.")
return value
def create(self, validated_data):
import os
validated_data["user"] = self.context["request"].user
name = str(getattr(validated_data.get("file"), "name", ""))
ext = os.path.splitext(name)[1].lower().lstrip(".")
if ext:
validated_data["format"] = ext
ebook = super().create(validated_data)
try:
apply_processing_to_ebook(ebook)
except Exception:
logger.exception("E-book processing failed for ebook %s", ebook.pk)
try:
enrich_ebook_metadata(ebook)
except Exception:
logger.exception("Metadata enrichment failed for ebook %s", ebook.pk)
ebook.refresh_from_db()
return ebook
class ReadingProgressSerializer(serializers.ModelSerializer):
class Meta:
model = ReadingProgress
fields = [
"current_position", "last_page", "epub_location",
"device_id", "device_name", "version", "updated_at",
]
read_only_fields = ["version", "updated_at"]
extra_kwargs = {"current_position": {"required": False, "min_value": 0.0, "max_value": 100.0}}
def validate_current_position(self, value):
if value < 0.0 or value > 100.0:
raise serializers.ValidationError("Position must be between 0.0 and 100.0.")
return value
class DownloadRecordSerializer(serializers.ModelSerializer):
ebook_id = serializers.IntegerField(source="ebook.id", read_only=True)
ebook_title = serializers.CharField(source="ebook.title", read_only=True)
author = serializers.CharField(source="ebook.author", read_only=True)
filename = serializers.SerializerMethodField()
cover_image = serializers.ImageField(source="ebook.cover_image", read_only=True)
format = serializers.CharField(source="ebook.format", read_only=True)
progress = serializers.SerializerMethodField()
file_url = serializers.SerializerMethodField()
class Meta:
model = DownloadRecord
fields = [
"id", "ebook_id", "ebook_title", "author", "filename", "file_url",
"file_size", "cover_image", "format", "downloaded_at", "progress",
]
def get_filename(self, obj):
return obj.ebook.filename()
def get_file_url(self, obj):
request = self.context.get("request")
if request and obj.ebook.file:
return request.build_absolute_uri(obj.ebook.file.url)
return ""
def get_progress(self, obj):
try:
rp = obj.ebook.reading_progress
return {
"current_position": rp.current_position,
"last_page": rp.last_page,
"epub_location": rp.epub_location,
}
except ReadingProgress.DoesNotExist:
return None
class StorageSummarySerializer(serializers.Serializer):
total_downloads = serializers.IntegerField()
total_size_bytes = serializers.IntegerField()
ebooks = serializers.ListField(child=serializers.DictField())
@@ -0,0 +1,8 @@
from __future__ import annotations
from apps.books.models import EBook
def subjects_from_ebook(ebook: EBook) -> list[str]:
ol = (ebook.metadata_json or {}).get("openlibrary") or {}
return [s.strip() for s in (ol.get("subjects") or []) if isinstance(s, str) and s.strip()]
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
import logging
from datetime import datetime, timezone
from django.core.files.base import ContentFile
from apps.books.models import EBook
from apps.books.services.openlibrary import (
HIGH_CONFIDENCE,
download_cover,
fetch_metadata,
)
logger = logging.getLogger(__name__)
def enrich_ebook_metadata(ebook: EBook) -> EBook:
"""Fetch Open Library metadata and update the ebook. Never raises to callers."""
user_title = ebook.title
user_author = ebook.author or ""
try:
result = fetch_metadata(user_title, user_author)
except Exception:
logger.exception("Open Library metadata fetch failed for ebook %s", ebook.pk)
return ebook
if result is None:
return ebook
now = datetime.now(timezone.utc).isoformat()
metadata = {
"source": "openlibrary",
"matched_at": now,
"match_language": result.match_language,
"match_score": result.match_score,
"match_status": result.match_status,
"user_input": {"title": user_title, "author": user_author},
"openlibrary": result.openlibrary,
}
ebook.metadata_json = metadata
update_fields = ["metadata_json", "updated_at"]
if result.match_status == "not_found":
ebook.save(update_fields=update_fields)
return ebook
ol = result.openlibrary
cover_id = ol.get("cover_id")
cover_missing = (
not ebook.cover_image
or not ebook.cover_image.name
or not ebook.cover_image.storage.exists(ebook.cover_image.name)
)
if cover_id and cover_missing:
if ebook.cover_image:
ebook.cover_image.delete(save=False)
cover_bytes = download_cover(int(cover_id))
if cover_bytes:
filename = f"ol_cover_{ebook.pk}_{cover_id}.jpg"
ebook.cover_image.save(filename, ContentFile(cover_bytes), save=False)
update_fields.append("cover_image")
if result.match_score >= HIGH_CONFIDENCE:
ol_title = ol.get("title")
ol_authors = ol.get("authors") or []
if ol_title:
ebook.title = ol_title[:512]
update_fields.append("title")
if ol_authors:
ebook.author = ol_authors[0][:256]
update_fields.append("author")
ebook.save(update_fields=list(dict.fromkeys(update_fields)))
return ebook
+304
View File
@@ -0,0 +1,304 @@
from __future__ import annotations
import logging
import re
import unicodedata
from dataclasses import dataclass
from difflib import SequenceMatcher
from typing import Any
import httpx
from config.settings import settings
logger = logging.getLogger(__name__)
SEARCH_URL = "https://openlibrary.org/search.json"
COVERS_URL = "https://covers.openlibrary.org/b/id/{cover_id}-{size}.jpg"
SEARCH_FIELDS = (
"key,title,author_name,cover_i,first_publish_year,subject,language,"
"edition_key,number_of_pages_median,publisher"
)
HIGH_CONFIDENCE = 0.8
LOW_CONFIDENCE = 0.6
# EPUB release noise often copied from filenames, e.g. "Title [6494] (r2.3)"
_TITLE_NOISE_PATTERNS = (
re.compile(r"\s*\[\d+\]"), # [6494]
re.compile(r"\s*\([rv][\d.]+\)", re.IGNORECASE), # (r2.3), (v1.0)
re.compile(r"\s*\(rev[\d.]*\)", re.IGNORECASE), # (rev2)
)
def _sanitize_search_title(title: str) -> str:
cleaned = title.strip()
for pattern in _TITLE_NOISE_PATTERNS:
cleaned = pattern.sub("", cleaned)
return re.sub(r"\s+", " ", cleaned).strip()
@dataclass(frozen=True)
class OpenLibraryHit:
work_key: str
title: str
authors: list[str]
cover_id: int | None
first_publish_year: int | None
subjects: list[str]
languages: list[str]
publishers: list[str]
edition_key: str | None
number_of_pages_median: int | None
match_language: str
score: float
@dataclass(frozen=True)
class OpenLibraryMetadata:
match_language: str
match_score: float
match_status: str
openlibrary: dict[str, Any]
def _normalize(text: str) -> str:
cleaned = re.sub(r"[^\w\s]", " ", _strip_accents(text).lower())
return re.sub(r"\s+", " ", cleaned).strip()
def _strip_accents(text: str) -> str:
normalized = unicodedata.normalize("NFKD", text)
return "".join(ch for ch in normalized if not unicodedata.combining(ch))
def _build_search_params(
title: str,
author: str,
*,
lang: str | None,
search_mode: str,
) -> dict[str, str | int]:
title = title.strip()
author = author.strip()
params: dict[str, str | int] = {"limit": 10, "fields": SEARCH_FIELDS}
if search_mode == "spanish_q":
q_parts = ["language:spa", title]
if author:
q_parts.append(author)
params["q"] = " ".join(q_parts)
elif search_mode == "q":
params["q"] = " ".join(part for part in (title, author) if part)
elif search_mode == "q_unaccent":
params["q"] = " ".join(
part for part in (_strip_accents(title), _strip_accents(author) if author else "") if part
)
else:
params["title"] = title
if author:
params["author"] = author
if lang:
params["lang"] = lang
return params
def _title_similarity(a: str, b: str) -> float:
na, nb = _normalize(a), _normalize(b)
if not na or not nb:
return 0.0
if na in nb or nb in na:
return 1.0
return SequenceMatcher(None, na, nb).ratio()
def _author_overlap(user_author: str, ol_authors: list[str]) -> float:
if not user_author.strip():
return 0.5 if ol_authors else 0.0
user_tokens = set(_normalize(user_author).split())
if not user_tokens:
return 0.0
best = 0.0
for name in ol_authors:
name_tokens = set(_normalize(name).split())
if not name_tokens:
continue
overlap = len(user_tokens & name_tokens) / len(user_tokens)
best = max(best, overlap)
if user_tokens <= name_tokens or name_tokens <= user_tokens:
best = max(best, 0.95)
return best
def _score_hit(title: str, author: str, doc: dict[str, Any], *, lang: str) -> float:
ol_title = doc.get("title") or ""
ol_authors = doc.get("author_name") or []
title_score = _title_similarity(title, ol_title)
author_score = _author_overlap(author, ol_authors)
combined = (title_score * 0.6) + (author_score * 0.4)
if doc.get("cover_i"):
combined += 0.05
return min(combined, 1.0)
def _parse_hit(doc: dict[str, Any], *, lang: str, score: float) -> OpenLibraryHit:
edition_keys = doc.get("edition_key") or []
edition_key = edition_keys[0] if edition_keys else None
cover_id = doc.get("cover_i")
return OpenLibraryHit(
work_key=doc.get("key") or "",
title=doc.get("title") or "",
authors=list(doc.get("author_name") or []),
cover_id=int(cover_id) if cover_id else None,
first_publish_year=doc.get("first_publish_year"),
subjects=list(doc.get("subject") or [])[:10],
languages=list(doc.get("language") or []),
publishers=list(doc.get("publisher") or [])[:5],
edition_key=edition_key,
number_of_pages_median=doc.get("number_of_pages_median"),
match_language=lang,
score=score,
)
def build_cover_url(cover_id: int, size: str = "L") -> str:
return COVERS_URL.format(cover_id=cover_id, size=size)
def _client() -> httpx.Client:
read_timeout = settings.OPENLIBRARY_TIMEOUT_SECONDS
connect_timeout = settings.OPENLIBRARY_CONNECT_TIMEOUT_SECONDS
return httpx.Client(
timeout=httpx.Timeout(
connect=connect_timeout,
read=read_timeout,
write=read_timeout,
pool=connect_timeout,
),
headers={"User-Agent": settings.OPENLIBRARY_USER_AGENT},
follow_redirects=True,
)
def search_works(
title: str,
author: str,
*,
lang: str | None = None,
search_mode: str = "title",
client: httpx.Client | None = None,
) -> list[OpenLibraryHit]:
if not title.strip():
return []
params = _build_search_params(title, author, lang=lang, search_mode=search_mode)
try:
if client is not None:
response = client.get(SEARCH_URL, params=params)
response.raise_for_status()
docs = response.json().get("docs") or []
else:
with _client() as owned_client:
response = owned_client.get(SEARCH_URL, params=params)
response.raise_for_status()
docs = response.json().get("docs") or []
except (httpx.HTTPError, ValueError) as exc:
logger.warning("Open Library search failed: %s", exc)
return []
hits: list[OpenLibraryHit] = []
for doc in docs:
score = _score_hit(title, author, doc, lang=lang or "")
if score < LOW_CONFIDENCE:
continue
hits.append(_parse_hit(doc, lang=lang or "", score=score))
hits.sort(key=lambda h: (h.score, h.cover_id is not None), reverse=True)
return hits
def pick_best_match(title: str, author: str, hits: list[OpenLibraryHit]) -> OpenLibraryHit | None:
return hits[0] if hits else None
def download_cover(cover_id: int) -> bytes | None:
url = build_cover_url(cover_id, size="L")
try:
with _client() as client:
response = client.get(url)
if response.status_code == 404:
return None
response.raise_for_status()
content_type = response.headers.get("content-type", "")
if not content_type.startswith("image/"):
return None
return response.content
except httpx.HTTPError as exc:
logger.warning("Open Library cover download failed for %s: %s", cover_id, exc)
return None
def _hit_to_openlibrary_dict(hit: OpenLibraryHit) -> dict[str, Any]:
return {
"work_key": hit.work_key,
"edition_key": hit.edition_key,
"title": hit.title,
"authors": hit.authors,
"cover_id": hit.cover_id,
"cover_url": build_cover_url(hit.cover_id) if hit.cover_id else None,
"first_publish_year": hit.first_publish_year,
"subjects": hit.subjects,
"languages": hit.languages,
"publishers": hit.publishers,
"number_of_pages_median": hit.number_of_pages_median,
}
def fetch_metadata(title: str, author: str) -> OpenLibraryMetadata | None:
if not settings.OPENLIBRARY_ENABLED:
return None
preferred = settings.OPENLIBRARY_PREFERRED_LANG
fallback = settings.OPENLIBRARY_FALLBACK_LANG
search_title = _sanitize_search_title(title)
if not search_title:
return OpenLibraryMetadata(
match_language=preferred,
match_score=0.0,
match_status="not_found",
openlibrary={},
)
search_plan: list[tuple[str, str | None]] = [
("spanish_q", preferred),
("title", preferred),
("q", None),
("q_unaccent", None),
]
if fallback != preferred:
search_plan.extend([("title", fallback), ("q", fallback)])
hits: list[OpenLibraryHit] = []
with _client() as client:
for search_mode, lang in search_plan:
hits = search_works(search_title, author, lang=lang, search_mode=search_mode, client=client)
if hits:
break
hit = pick_best_match(title, author, hits)
if not hit:
return OpenLibraryMetadata(
match_language=preferred,
match_score=0.0,
match_status="not_found",
openlibrary={},
)
status = "matched" if hit.score >= HIGH_CONFIDENCE else "partial"
return OpenLibraryMetadata(
match_language=hit.match_language,
match_score=hit.score,
match_status=status,
openlibrary=_hit_to_openlibrary_dict(hit),
)
@@ -0,0 +1,239 @@
"""Extract TOC, metadata, and page count from uploaded EPUB/PDF files."""
from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Any
from apps.books.models import BookChapter, EBook
logger = logging.getLogger(__name__)
def process_ebook(file_path: str, original_filename: str | None = None) -> dict[str, Any]:
"""Parse an e-book file and return format, metadata, TOC, and page count."""
ext = Path(original_filename or file_path).suffix.lower()
if ext == ".epub" or file_path.lower().endswith(".epub"):
return _process_epub(file_path)
if ext == ".pdf" or file_path.lower().endswith(".pdf"):
return _process_pdf(file_path)
return {"format": ext.lstrip(".") or "unknown", "page_count": 0, "metadata": {}, "toc": []}
def store_chapters(ebook: EBook, toc: list[dict[str, Any]], parent_index: int = 0) -> None:
"""Recursively store TOC entries as BookChapter records."""
for idx, entry in enumerate(toc):
BookChapter.objects.create(
ebook=ebook,
title=entry.get("title", "Untitled"),
index=parent_index + idx,
href=entry.get("href", ""),
children=entry.get("children", []),
)
children = entry.get("children", [])
if children:
store_chapters(ebook, children, parent_index + idx + 1)
def apply_processing_to_ebook(ebook: EBook) -> dict[str, Any]:
"""Run processing on an EBook instance and persist chapters + metadata."""
if not ebook.file:
raise ValueError("No file found for this e-book.")
file_path = ebook.file.path
result = process_ebook(file_path, original_filename=ebook.filename())
ebook.format = result.get("format", ebook.format)
ebook.page_count = result.get("page_count", 0)
file_metadata = result.get("metadata") or {}
if file_metadata:
merged = dict(ebook.metadata_json or {})
merged["file"] = file_metadata
ebook.metadata_json = merged
ebook.save(update_fields=["format", "page_count", "metadata_json", "updated_at"])
raw_toc: list[dict[str, Any]] = result.get("toc", [])
BookChapter.objects.filter(ebook=ebook).delete()
store_chapters(ebook, raw_toc)
return {
"format": ebook.format,
"page_count": ebook.page_count,
"metadata": ebook.metadata_json,
"toc_count": len(raw_toc),
"status": "processed",
}
def _process_epub(file_path: str) -> dict[str, Any]:
from ebooklib import epub, ITEM_DOCUMENT
book = epub.read_epub(file_path)
metadata = _extract_epub_metadata(book)
toc = _extract_epub_toc(book)
if not toc:
toc = _fallback_toc_from_spine(book, ITEM_DOCUMENT)
flat_count = _count_toc_entries(toc)
page_count = flat_count or len(book.spine)
return {
"format": "epub",
"page_count": page_count,
"metadata": metadata,
"toc": toc,
}
def _process_pdf(file_path: str) -> dict[str, Any]:
from pypdf import PdfReader
reader = PdfReader(file_path)
page_count = len(reader.pages)
metadata = _extract_pdf_metadata(reader)
toc = _extract_pdf_outline(reader)
if not toc and page_count > 0:
toc = [
{"title": f"Page {i}", "href": f"pdf:page:{i}", "children": []}
for i in range(1, page_count + 1)
]
return {
"format": "pdf",
"page_count": page_count,
"metadata": metadata,
"toc": toc,
}
def _extract_pdf_metadata(reader) -> dict[str, Any]:
metadata: dict[str, Any] = {}
info = reader.metadata
if not info:
return metadata
title = getattr(info, "title", None)
author = getattr(info, "author", None)
if title:
metadata["title"] = str(title)
if author:
metadata["author"] = str(author)
return metadata
def _extract_pdf_outline(reader) -> list[dict[str, Any]]:
outline = getattr(reader, "outline", None)
if not outline:
return []
return _walk_pdf_outline(reader, outline)
def _walk_pdf_outline(reader, outline: list[Any]) -> list[dict[str, Any]]:
entries: list[dict[str, Any]] = []
i = 0
while i < len(outline):
item = outline[i]
if isinstance(item, list):
if entries:
entries[-1]["children"] = _walk_pdf_outline(reader, item)
i += 1
continue
title = getattr(item, "title", None) or "Section"
page_num = 1
try:
page_num = reader.get_destination_page_number(item) + 1
except Exception:
logger.debug("Could not resolve PDF outline destination", exc_info=True)
entries.append({
"title": str(title),
"href": f"pdf:page:{page_num}",
"children": [],
})
i += 1
return entries
def _extract_epub_metadata(book) -> dict[str, Any]:
metadata: dict[str, Any] = {}
def first(namespace: str, name: str) -> str:
values = book.get_metadata(namespace, name)
if values:
return str(values[0][0])
return ""
title = first("DC", "title")
if title:
metadata["title"] = title
creator = first("DC", "creator")
if creator:
metadata["author"] = creator
language = first("DC", "language")
if language:
metadata["language"] = language
identifier = first("DC", "identifier")
if identifier:
metadata["identifier"] = identifier
return metadata
def _parse_toc_item(item) -> dict[str, Any]:
from ebooklib import epub
if isinstance(item, epub.Link):
return {
"title": item.title or "Untitled",
"href": item.href or "",
"children": [],
}
if isinstance(item, tuple):
section, children = item
entry = {
"title": getattr(section, "title", None) or "Untitled",
"href": getattr(section, "href", None) or "",
"children": [],
}
for child in children:
entry["children"].append(_parse_toc_item(child))
return entry
if hasattr(item, "title"):
return {
"title": item.title or "Untitled",
"href": getattr(item, "href", "") or "",
"children": [],
}
return {"title": "Untitled", "href": "", "children": []}
def _extract_epub_toc(book) -> list[dict[str, Any]]:
return [_parse_toc_item(item) for item in book.toc]
def _fallback_toc_from_spine(book, item_document_type) -> list[dict[str, Any]]:
toc: list[dict[str, Any]] = []
seen: set[str] = set()
chapter_num = 0
for spine_entry in book.spine:
item_id = spine_entry[0] if isinstance(spine_entry, tuple) else spine_entry
item = book.get_item_with_id(item_id)
if not item or item.get_type() != item_document_type:
continue
href = item.get_name() or ""
if not href or href in seen:
continue
seen.add(href)
chapter_num += 1
title = os.path.splitext(os.path.basename(href))[0] or f"Chapter {chapter_num}"
toc.append({"title": title.replace("_", " ").replace("-", " "), "href": href, "children": []})
return toc
def _count_toc_entries(toc: list[dict[str, Any]]) -> int:
count = 0
for entry in toc:
count += 1
count += _count_toc_entries(entry.get("children", []))
return count
+255
View File
@@ -0,0 +1,255 @@
"""Tests for the Book search & discovery endpoints."""
import pytest
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from apps.books.models import Book, ReadingStatus
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def api_client():
return APIClient()
@pytest.fixture
def user(django_user_model):
return django_user_model.objects.create_user(
email="reader@example.com",
password="testpass123",
)
@pytest.fixture
def auth_client(api_client, user):
api_client.force_authenticate(user=user)
return api_client
@pytest.fixture
def books():
books_data = [
Book.objects.create(
title="Dune",
author="Frank Herbert",
genre="Science Fiction",
reading_status=ReadingStatus.FINISHED,
total_pages=688,
description="A desert planet saga.",
),
Book.objects.create(
title="Neuromancer",
author="William Gibson",
genre="Science Fiction",
reading_status=ReadingStatus.READING,
total_pages=271,
description="Cyberpunk classic.",
),
Book.objects.create(
title="The Hobbit",
author="J.R.R. Tolkien",
genre="Fantasy",
reading_status=ReadingStatus.WANT_TO_READ,
total_pages=310,
description="A hobbit's adventure.",
),
Book.objects.create(
title="1984",
author="George Orwell",
genre="Dystopian",
reading_status=ReadingStatus.FINISHED,
total_pages=328,
description="Big Brother is watching.",
),
]
return books_data
# ---------------------------------------------------------------------------
# Search tests
# ---------------------------------------------------------------------------
@pytest.mark.django_db
class TestBookSearch:
"""Verify the search endpoint returns correct results."""
def test_search_by_title(self, auth_client, books):
url = reverse("book-list")
response = auth_client.get(url, {"q": "Dune"})
assert response.status_code == status.HTTP_200_OK
titles = [b["title"] for b in response.data["results"]]
assert "Dune" in titles
assert "Neuromancer" not in titles
def test_search_by_author(self, auth_client, books):
url = reverse("book-list")
response = auth_client.get(url, {"q": "Tolkien"})
assert response.status_code == status.HTTP_200_OK
titles = [b["title"] for b in response.data["results"]]
assert "The Hobbit" in titles
def test_search_by_genre(self, auth_client, books):
url = reverse("book-list")
response = auth_client.get(url, {"q": "Fantasy"})
assert response.status_code == status.HTTP_200_OK
titles = [b["title"] for b in response.data["results"]]
assert "The Hobbit" in titles
def test_search_case_insensitive(self, auth_client, books):
url = reverse("book-list")
response = auth_client.get(url, {"q": "dune"})
assert response.status_code == status.HTTP_200_OK
assert any(b["title"] == "Dune" for b in response.data["results"])
def test_search_partial_match(self, auth_client, books):
url = reverse("book-list")
response = auth_client.get(url, {"q": "Neu"})
assert response.status_code == status.HTTP_200_OK
titles = [b["title"] for b in response.data["results"]]
assert "Neuromancer" in titles
def test_search_empty_query_returns_all(self, auth_client, books):
url = reverse("book-list")
response = auth_client.get(url, {"q": ""})
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 4
def test_search_no_results(self, auth_client, books):
url = reverse("book-list")
response = auth_client.get(url, {"q": "zzzznotfound"})
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 0
# ---------------------------------------------------------------------------
# Filter tests
# ---------------------------------------------------------------------------
@pytest.mark.django_db
class TestBookFilters:
"""Verify filters for genre, author, and reading_status."""
def test_filter_by_genre(self, auth_client, books):
url = reverse("book-list")
response = auth_client.get(url, {"genre": "Science Fiction"})
assert response.status_code == status.HTTP_200_OK
titles = [b["title"] for b in response.data["results"]]
assert "Dune" in titles
assert "Neuromancer" in titles
assert "The Hobbit" not in titles
def test_filter_by_author(self, auth_client, books):
url = reverse("book-list")
response = auth_client.get(url, {"author": "George Orwell"})
assert response.status_code == status.HTTP_200_OK
titles = [b["title"] for b in response.data["results"]]
assert "1984" in titles
assert "Dune" not in titles
def test_filter_by_reading_status(self, auth_client, books):
url = reverse("book-list")
response = auth_client.get(url, {"reading_status": ReadingStatus.FINISHED})
assert response.status_code == status.HTTP_200_OK
titles = [b["title"] for b in response.data["results"]]
assert "Dune" in titles
assert "1984" in titles
assert "Neuromancer" not in titles
assert "The Hobbit" not in titles
def test_filter_combined_with_search(self, auth_client, books):
"""Search + filter should intersect results."""
url = reverse("book-list")
response = auth_client.get(url, {"q": "Dune", "reading_status": ReadingStatus.FINISHED})
assert response.status_code == status.HTTP_200_OK
titles = [b["title"] for b in response.data["results"]]
assert "Dune" in titles
# 1984 matches reading_status but not search
assert "1984" not in titles
# ---------------------------------------------------------------------------
# Discovery endpoints
# ---------------------------------------------------------------------------
@pytest.mark.django_db
class TestBookDiscovery:
"""Verify genre and author discovery endpoints."""
def test_genres_endpoint(self, auth_client, books):
url = reverse("book-genres")
response = auth_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert isinstance(response.data, list)
assert "Science Fiction" in response.data
assert "Fantasy" in response.data
assert "Dystopian" in response.data
# No duplicate genres
assert response.data.count("Science Fiction") == 1
def test_authors_endpoint(self, auth_client, books):
url = reverse("book-authors")
response = auth_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert isinstance(response.data, list)
assert "Frank Herbert" in response.data
assert "J.R.R. Tolkien" in response.data
def test_genres_requires_auth(self, api_client, books):
url = reverse("book-genres")
response = api_client.get(url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_authors_requires_auth(self, api_client, books):
url = reverse("book-authors")
response = api_client.get(url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# ---------------------------------------------------------------------------
# Detail view
# ---------------------------------------------------------------------------
@pytest.mark.django_db
class TestBookDetail:
"""Verify the book detail endpoint."""
def test_retrieve_book(self, auth_client, books):
book = books[0]
url = reverse("book-detail", kwargs={"pk": book.pk})
response = auth_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert response.data["title"] == "Dune"
assert response.data["author"] == "Frank Herbert"
assert response.data["description"] == "A desert planet saga."
assert response.data["total_pages"] == 688
def test_retrieve_nonexistent_returns_404(self, auth_client, books):
url = reverse("book-detail", kwargs={"pk": 99999})
response = auth_client.get(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_pagination(self, auth_client, books):
"""List with small page size should paginate."""
url = reverse("book-list")
response = auth_client.get(f"{url}?page_size=2")
assert response.status_code == status.HTTP_200_OK
assert "count" in response.data
assert "results" in response.data
assert response.data["count"] == 4
def test_ordering(self, auth_client, books):
url = reverse("book-list")
response = auth_client.get(url, {"ordering": "title"})
assert response.status_code == status.HTTP_200_OK
titles = [b["title"] for b in response.data["results"]]
assert titles == sorted(titles)
+4 -2
View File
@@ -1,11 +1,13 @@
from django.urls import include, path from django.urls import include, path
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from apps.books.views import BookViewSet from apps.books.views import BookViewSet, EBookViewSet, book_reading_settings_view
router = DefaultRouter() router = DefaultRouter()
router.register(r"ebooks", EBookViewSet, basename="ebook")
router.register(r"", BookViewSet, basename="book") router.register(r"", BookViewSet, basename="book")
urlpatterns = [ urlpatterns = [
path("settings/", book_reading_settings_view, name="book-settings"),
path("", include(router.urls)), path("", include(router.urls)),
] ]
+314 -12
View File
@@ -1,24 +1,326 @@
from django_filters.rest_framework import DjangoFilterBackend from __future__ import annotations
from rest_framework import viewsets
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.permissions import IsAuthenticated
from apps.books.models import Book import logging
from apps.books.serializers import BookListSerializer, BookSerializer from typing import Any
from django.db.models import QuerySet, Q
from django.http import FileResponse
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import parsers, permissions, status, viewsets
from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from apps.books.models import Book, BookChapter, DownloadRecord, EBook, ReadingProgress
from apps.books.serializers import (
BookChapterSerializer, BookDetailSerializer, BookListSerializer, BookReadingSettingsSerializer,
BookSerializer, DownloadRecordSerializer, EBookContentSerializer, EBookDetailSerializer,
EBookListSerializer, EBookTocSerializer, EBookUploadSerializer,
ReadingProgressSerializer, StorageSummarySerializer,
)
from apps.reader.models import ReadingSettings
from apps.books.services.ebook_metadata import subjects_from_ebook
from apps.books.services.metadata import enrich_ebook_metadata
logger = logging.getLogger(__name__)
class BookViewSet(viewsets.ModelViewSet): class BookViewSet(viewsets.ModelViewSet):
"""CRUD for books."""
queryset = Book.objects.all() queryset = Book.objects.all()
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_fields = ["author"] filterset_fields = ["author", "genre", "reading_status"]
search_fields = ["title", "author"] search_fields = ["title", "author", "genre"]
ordering_fields = ["title", "author", "created_at"] ordering_fields = ["title", "author", "genre", "created_at"]
ordering = ["title"] ordering = ["title"]
def get_serializer_class(self): def get_serializer_class(self):
if self.action == "retrieve":
return BookDetailSerializer
if self.action == "list": if self.action == "list":
return BookListSerializer return BookListSerializer
return BookSerializer return BookSerializer
def get_queryset(self) -> QuerySet[Book]:
qs = super().get_queryset()
query = self.request.query_params.get("q", "").strip()
if query:
qs = qs.filter(Q(title__icontains=query) | Q(author__icontains=query) | Q(genre__icontains=query))
return qs
@action(detail=False, methods=["get"], permission_classes=[AllowAny])
def genres(self, request: Request) -> Response:
genre_list = Book.objects.values_list("genre", flat=True).distinct().order_by("genre")
return Response([g for g in genre_list if g])
@action(detail=False, methods=["get"], permission_classes=[AllowAny])
def authors(self, request: Request) -> Response:
author_list = Book.objects.values_list("author", flat=True).distinct().order_by("author")
return Response([a for a in author_list if a])
@action(detail=False, methods=["get"])
def storage(self, request: Request) -> Response:
"""Return storage usage summary for the current user."""
download_records = DownloadRecord.objects.filter(user=request.user).select_related("ebook")
total_size = sum(r.file_size for r in download_records)
ebook_list = [
{"id": r.ebook.id, "title": r.ebook.title, "file_size": r.file_size}
for r in download_records
]
serializer = StorageSummarySerializer(data={
"total_downloads": download_records.count(),
"total_size_bytes": total_size,
"ebooks": ebook_list,
})
serializer.is_valid(raise_exception=True)
return Response(serializer.data)
class IsEBookOwner(permissions.BasePermission):
def has_object_permission(self, request: Request, view: object, obj: EBook) -> bool:
return obj.user == request.user
class EBookViewSet(viewsets.ModelViewSet):
parser_classes = [parsers.MultiPartParser, parsers.FormParser, parsers.JSONParser]
permission_classes = [IsAuthenticated, IsEBookOwner]
def get_serializer_class(self):
if self.action == "create":
return EBookUploadSerializer
if self.action in ("list",):
return EBookListSerializer
if self.action in ("toc",):
return BookChapterSerializer
return EBookDetailSerializer
def get_queryset(self):
qs = EBook.objects.filter(user=self.request.user).select_related("reading_progress", "user")
query = self.request.query_params.get("q", "").strip()
if query and self.action == "list":
qs = qs.filter(
Q(title__icontains=query) | Q(author__icontains=query),
)
return qs
@action(detail=False, methods=["get"])
def genres(self, request: Request) -> Response:
genre_set: set[str] = set()
for ebook in self.get_queryset():
genre_set.update(subjects_from_ebook(ebook))
return Response(sorted(genre_set))
@action(detail=False, methods=["get"])
def authors(self, request: Request) -> Response:
author_list = (
self.get_queryset()
.values_list("author", flat=True)
.distinct()
.order_by("author")
)
return Response([a for a in author_list if a])
@action(detail=True, methods=["get", "patch"])
def progress(self, request: Request, pk: int | None = None) -> Response:
ebook = self.get_object()
progress_obj, _created = ReadingProgress.objects.get_or_create(user=request.user, ebook=ebook)
if request.method == "GET":
serializer = ReadingProgressSerializer(progress_obj)
return Response(serializer.data)
serializer = ReadingProgressSerializer(progress_obj, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
@action(detail=True, methods=["post"], url_path="enrich-metadata")
def enrich_metadata(self, request: Request, pk: int | None = None) -> Response:
"""Re-fetch Open Library metadata and cover for this ebook."""
ebook = self.get_object()
try:
enrich_ebook_metadata(ebook)
except Exception:
logger.exception("Manual metadata enrichment failed for ebook %s", ebook.id)
return Response(
{"error": "Metadata enrichment failed."},
status=status.HTTP_502_BAD_GATEWAY,
)
ebook.refresh_from_db()
serializer = EBookDetailSerializer(ebook, context={"request": request})
return Response(serializer.data)
@action(detail=True, methods=["post"])
def process(self, request: Request, pk: int | None = None) -> Response:
"""Trigger e-book processing: metadata extraction, TOC building, page counting."""
ebook = self.get_object()
if not ebook.file:
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
try:
from apps.books.services.process_ebook import apply_processing_to_ebook
result = apply_processing_to_ebook(ebook)
return Response(result)
except Exception as exc:
logger.exception("Failed to process ebook %s", ebook.id)
return Response({"error": f"Processing failed: {exc}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=True, methods=["get"])
def file(self, request: Request, pk: int | None = None) -> FileResponse | Response:
"""Stream the ebook file for authenticated in-browser reading."""
ebook = self.get_object()
content_types = {
"epub": "application/epub+zip",
"pdf": "application/pdf",
}
content_type = content_types.get(ebook.format)
if not content_type:
return Response(
{"error": "Unsupported format for in-browser reading."},
status=status.HTTP_400_BAD_REQUEST,
)
if not ebook.file:
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
return FileResponse(
ebook.file.open("rb"),
content_type=content_type,
filename=ebook.filename(),
)
@action(detail=True, methods=["get"])
def toc(self, request: Request, pk: int | None = None) -> Response:
"""Return hierarchical table of contents."""
ebook = self.get_object()
chapters = BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook")
serializer = BookChapterSerializer(chapters, many=True)
return Response({
"chapters": serializer.data,
"format": ebook.format,
"page_count": ebook.page_count,
})
@action(detail=True, methods=["get"])
def content(self, request: Request, pk: int | None = None) -> Response:
"""Return paginated content for a given page number.
Query params:
page (int): page/chapter index to fetch (1-indexed, default: 1)
"""
ebook = self.get_object()
page = max(1, int(request.query_params.get("page", 1)))
chapters = list(BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook"))
total_pages = len(chapters) or ebook.page_count or 1
chapter: BookChapter | None = None
chapter_title = ""
content_html = ""
if chapters and 0 <= page - 1 < len(chapters):
ch = chapters[page - 1]
chapter_title = ch.title
content_html = _fetch_chapter_content(ebook, ch)
serializer = EBookContentSerializer(data={
"page": page,
"total_pages": total_pages,
"content": content_html,
"chapter_title": chapter_title,
"format": ebook.format,
})
serializer.is_valid(raise_exception=True)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def download(self, request: Request, pk: int | None = None) -> Response:
"""Track download of an e-book. Creates a DownloadRecord and returns file info."""
ebook = self.get_object()
if not ebook.file:
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
download, created = DownloadRecord.objects.get_or_create(
user=request.user,
ebook=ebook,
defaults={"file_size": ebook.file.size if ebook.file else 0},
)
if not created:
download.file_size = ebook.file.size if ebook.file else 0
download.save(update_fields=["file_size"])
serializer = DownloadRecordSerializer(download, context={"request": request})
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"])
def downloads(self, request: Request) -> Response:
"""List all e-books the current user has downloaded."""
records = DownloadRecord.objects.filter(user=request.user).select_related(
"ebook", "ebook__reading_progress"
).prefetch_related("ebook__chapters")
page = self.paginate_queryset(records)
if page is not None:
serializer = DownloadRecordSerializer(page, many=True, context={"request": request})
return self.get_paginated_response(serializer.data)
serializer = DownloadRecordSerializer(records, many=True, context={"request": request})
return Response(serializer.data)
@action(detail=False, methods=["delete"], url_path="downloads/(?P<download_pk>[^/.]+)")
def delete_download(self, request: Request, download_pk: str | None = None) -> Response:
"""Delete a download record."""
try:
download = DownloadRecord.objects.get(pk=download_pk, user=request.user)
except DownloadRecord.DoesNotExist:
return Response({"error": "Download record not found."}, status=status.HTTP_404_NOT_FOUND)
download.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def _fetch_chapter_content(ebook: EBook, chapter: BookChapter) -> str:
"""Fetch HTML content for a chapter from the e-book file."""
if ebook.format == "epub":
return _fetch_epub_chapter_content(ebook.file.path, chapter)
return ""
def _fetch_epub_chapter_content(file_path: str, chapter: BookChapter) -> str:
"""Extract HTML content of a specific EPUB chapter by href."""
try:
from ebooklib import epub
from bs4 import BeautifulSoup
except ImportError:
return ""
try:
book = epub.read_epub(file_path)
href = chapter.href or ""
# Find the item by href
for item in book.get_items():
item_name = item.get_name() or ""
if href and (item_name.endswith(href) or href.endswith(item_name)):
content = item.get_content()
soup = BeautifulSoup(content, "html.parser")
# Clean up — remove body/html/head wrappers, keep inner content
body = soup.find("body")
if body:
return str(body)
return str(soup)
return ""
except Exception:
logger.exception("Failed to fetch EPUB chapter content for %s", chapter.href)
return ""
@api_view(["GET", "PATCH"])
@permission_classes([IsAuthenticated])
def book_reading_settings_view(request: Request) -> Response:
"""Get or update reading settings via the books API contract."""
settings, _created = ReadingSettings.objects.get_or_create(user=request.user)
if request.method == "GET":
serializer = BookReadingSettingsSerializer(settings)
return Response(serializer.data)
serializer = BookReadingSettingsSerializer(settings, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
View File
@@ -1,6 +1,7 @@
from django.apps import AppConfig from django.apps import AppConfig
class BooksConfig(AppConfig): class ReaderConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "books" name = "apps.reader"
verbose_name = "Reader Settings"
@@ -0,0 +1,39 @@
# Generated by Django 5.1.7 on 2026-05-29 06:29
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('users', '__first__'),
]
operations = [
migrations.CreateModel(
name='ReadingSettings',
fields=[
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='reading_settings', serialize=False, to=settings.AUTH_USER_MODEL)),
('font_family', models.CharField(choices=[('sans-serif', 'Sans-serif'), ('serif', 'Serif'), ('monospace', 'Monospace')], default='serif', max_length=32)),
('font_size', models.PositiveSmallIntegerField(default=18)),
('line_height', models.FloatField(default=1.6)),
('margin_width', models.PositiveSmallIntegerField(default=16)),
('background_color', models.CharField(default='#f5f0eb', max_length=7)),
('text_color', models.CharField(default='#1a1a1a', max_length=7)),
('brightness', models.PositiveSmallIntegerField(default=100)),
('orientation_lock', models.CharField(choices=[('auto', 'Auto'), ('portrait', 'Portrait'), ('landscape', 'Landscape')], default='auto', max_length=16)),
('theme', models.CharField(choices=[('sepia', 'Sepia'), ('dark', 'Dark'), ('light', 'Light'), ('paper', 'Paper')], default='sepia', max_length=32)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Reading Settings',
'verbose_name_plural': 'Reading Settings',
'db_table': 'reader_reading_settings',
},
),
]
+53
View File
@@ -0,0 +1,53 @@
from django.conf import settings
from django.db import models
class ReadingSettings(models.Model):
"""Per-user reading preferences for the e-book reader view."""
THEME_CHOICES = [
("sepia", "Sepia"),
("dark", "Dark"),
("light", "Light"),
("paper", "Paper"),
]
FONT_CHOICES = [
("sans-serif", "Sans-serif"),
("serif", "Serif"),
("monospace", "Monospace"),
]
ORIENTATION_CHOICES = [
("auto", "Auto"),
("portrait", "Portrait"),
("landscape", "Landscape"),
]
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="reading_settings",
primary_key=True,
)
font_family = models.CharField(max_length=32, choices=FONT_CHOICES, default="serif")
font_size = models.PositiveSmallIntegerField(default=18)
line_height = models.FloatField(default=1.6)
margin_width = models.PositiveSmallIntegerField(default=16)
background_color = models.CharField(max_length=7, default="#f5f0eb")
text_color = models.CharField(max_length=7, default="#1a1a1a")
brightness = models.PositiveSmallIntegerField(default=100)
orientation_lock = models.CharField(
max_length=16, choices=ORIENTATION_CHOICES, default="auto"
)
theme = models.CharField(max_length=32, choices=THEME_CHOICES, default="sepia")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "reader_reading_settings"
verbose_name = "Reading Settings"
verbose_name_plural = "Reading Settings"
def __str__(self) -> str:
return f"{self.user}{self.theme} ({self.font_size}px)"
+63
View File
@@ -0,0 +1,63 @@
from rest_framework import serializers
from apps.reader.models import ReadingSettings
# Theme presets mapped to colors
THEME_COLORS = {
"sepia": {"background_color": "#f5f0eb", "text_color": "#1a1a1a"},
"dark": {"background_color": "#1a1a2e", "text_color": "#e0e0e0"},
"light": {"background_color": "#ffffff", "text_color": "#1a1a1a"},
"paper": {"background_color": "#e8e0d4", "text_color": "#2c2c2c"},
}
class ReadingSettingsSerializer(serializers.ModelSerializer):
"""Serialize ReadingSettings for the current user."""
class Meta:
model = ReadingSettings
fields = [
"font_family",
"font_size",
"line_height",
"margin_width",
"background_color",
"text_color",
"brightness",
"orientation_lock",
"theme",
"created_at",
"updated_at",
]
read_only_fields = ["created_at", "updated_at"]
def validate_font_size(self, value: int) -> int:
if value < 12 or value > 32:
raise serializers.ValidationError("Font size must be between 12 and 32.")
return value
def validate_line_height(self, value: float) -> float:
if value < 1.2 or value > 2.0:
raise serializers.ValidationError("Line height must be between 1.2 and 2.0.")
return value
def validate_margin_width(self, value: int) -> int:
if value < 8 or value > 48:
raise serializers.ValidationError("Margin width must be between 8 and 48.")
return value
def validate_brightness(self, value: int) -> int:
if value < 0 or value > 100:
raise serializers.ValidationError("Brightness must be between 0 and 100.")
return value
def validate(self, attrs):
"""Sync theme colors when theme changes, unless explicit colors provided."""
theme = attrs.get("theme")
if theme and theme in THEME_COLORS:
# Only auto-set colors if not explicitly provided
if "background_color" not in attrs:
attrs["background_color"] = THEME_COLORS[theme]["background_color"]
if "text_color" not in attrs:
attrs["text_color"] = THEME_COLORS[theme]["text_color"]
return attrs
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from apps.reader.views import reading_settings_view
urlpatterns = [
path("settings/", reading_settings_view, name="reading-settings"),
]
+33
View File
@@ -0,0 +1,33 @@
from rest_framework import permissions, status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.request import Request
from rest_framework.response import Response
from apps.reader.models import ReadingSettings
from apps.reader.serializers import ReadingSettingsSerializer
@api_view(["GET", "PUT", "PATCH"])
@permission_classes([permissions.IsAuthenticated])
def reading_settings_view(request: Request) -> Response:
"""Get or update the current user's reading settings.
GET return existing settings (auto-create defaults if missing)
PUT create or fully replace settings
PATCH partial update
"""
user = request.user
settings, created = ReadingSettings.objects.get_or_create(user=user)
if request.method == "GET":
serializer = ReadingSettingsSerializer(settings)
return Response(serializer.data)
if request.method == "PUT":
serializer = ReadingSettingsSerializer(settings, data=request.data)
elif request.method == "PATCH":
serializer = ReadingSettingsSerializer(settings, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -0,0 +1,44 @@
# Generated by Django 5.1.7 on 2026-06-03 22:26
import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'User',
'verbose_name_plural': 'Users',
'db_table': 'users_user',
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]
+93
View File
@@ -0,0 +1,93 @@
import re
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from rest_framework import serializers
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
User = get_user_model()
def _derive_username(email: str) -> str:
local = email.split("@", 1)[0]
candidate = re.sub(r"[^\w.@+-]", "_", local).strip("._")
return candidate[:150] if candidate else "user"
def _unique_username(base: str) -> str:
username = base[:150]
if not User.objects.filter(username=username).exists():
return username
suffix = 1
while User.objects.filter(username=f"{username[:140]}_{suffix}").exists():
suffix += 1
return f"{username[:140]}_{suffix}"
class RegisterSerializer(serializers.Serializer):
email = serializers.EmailField()
password = serializers.CharField(write_only=True, min_length=8)
def validate_email(self, value: str) -> str:
email = value.lower()
if User.objects.filter(email__iexact=email).exists():
raise serializers.ValidationError("A user with this email already exists.")
return email
def validate_password(self, value: str) -> str:
validate_password(value)
return value
def create(self, validated_data: dict) -> User:
email = validated_data["email"]
username = _unique_username(_derive_username(email))
return User.objects.create_user(
username=username,
email=email,
password=validated_data["password"],
)
def to_representation(self, instance: User) -> dict:
return {
"id": instance.id,
"email": instance.email,
"username": instance.username,
}
class EmailTokenObtainPairSerializer(TokenObtainPairSerializer):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.fields.pop(self.username_field, None)
self.fields["email"] = serializers.EmailField(required=True)
def validate(self, attrs: dict) -> dict:
email = attrs.get("email", "").lower()
password = attrs.get("password")
try:
user = User.objects.get(email__iexact=email)
except User.DoesNotExist as exc:
raise serializers.ValidationError(
{"detail": "No active account found with the given credentials."}
) from exc
if not user.check_password(password):
raise serializers.ValidationError(
{"detail": "No active account found with the given credentials."}
)
if not user.is_active:
raise serializers.ValidationError({"detail": "User account is disabled."})
refresh = self.get_token(user)
return {
"refresh": str(refresh),
"access": str(refresh.access_token),
}
@classmethod
def get_token(cls, user: User) -> object:
token = super().get_token(user)
token["email"] = user.email
return token
+8 -5
View File
@@ -1,8 +1,11 @@
from django.urls import include, path from django.urls import path
from rest_framework.routers import DefaultRouter from rest_framework_simplejwt.views import TokenRefreshView
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from apps.users.views import EmailTokenObtainPairView, RegisterView
urlpatterns = [ urlpatterns = [
path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), path("register/", RegisterView.as_view(), name="register"),
path("token/", EmailTokenObtainPairView.as_view(), name="token_obtain_pair"),
path("login/", EmailTokenObtainPairView.as_view(), name="login"),
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
] ]
+14
View File
@@ -0,0 +1,14 @@
from rest_framework import generics
from rest_framework.permissions import AllowAny
from rest_framework_simplejwt.views import TokenObtainPairView
from apps.users.serializers import EmailTokenObtainPairSerializer, RegisterSerializer
class RegisterView(generics.CreateAPIView):
serializer_class = RegisterSerializer
permission_classes = [AllowAny]
class EmailTokenObtainPairView(TokenObtainPairView):
serializer_class = EmailTokenObtainPairSerializer
+7 -2
View File
@@ -40,6 +40,7 @@ INSTALLED_APPS = [
"apps.users", "apps.users",
"apps.books", "apps.books",
"apps.annotations", "apps.annotations",
"apps.reader",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -140,13 +141,17 @@ USE_I18N = True
USE_TZ = True USE_TZ = True
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Static / Media # Static / Media / Uploads
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
STATIC_URL = "static/" STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles" STATIC_ROOT = BASE_DIR / "staticfiles"
MEDIA_URL = "media/" MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media" MEDIA_ROOT = BASE_DIR / "media"
# Maximum upload size: 50MB
DATA_UPLOAD_MAX_MEMORY_SIZE = 52_428_800
FILE_UPLOAD_MAX_MEMORY_SIZE = 52_428_800
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Default primary key # Default primary key
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+9 -4
View File
@@ -1,6 +1,3 @@
import os
from pathlib import Path
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
@@ -29,6 +26,14 @@ class Settings(BaseSettings):
"http://localhost:3000", "http://localhost:3000",
] ]
# Open Library metadata enrichment
OPENLIBRARY_ENABLED: bool = True
OPENLIBRARY_PREFERRED_LANG: str = "es"
OPENLIBRARY_FALLBACK_LANG: str = "en"
OPENLIBRARY_TIMEOUT_SECONDS: float = 10.0
OPENLIBRARY_CONNECT_TIMEOUT_SECONDS: float = 15.0
OPENLIBRARY_USER_AGENT: str = "CloudReader/1.0 (https://github.com/cloud-reader)"
@property @property
def DATABASE_URL(self) -> str: def DATABASE_URL(self) -> str:
return ( return (
@@ -39,4 +44,4 @@ class Settings(BaseSettings):
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
settings = Settings() settings = Settings()
+8 -1
View File
@@ -1,3 +1,4 @@
from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
@@ -6,4 +7,10 @@ urlpatterns = [
path("api/auth/", include("apps.users.urls")), path("api/auth/", include("apps.users.urls")),
path("api/books/", include("apps.books.urls")), path("api/books/", include("apps.books.urls")),
path("api/annotations/", include("apps.annotations.urls")), path("api/annotations/", include("apps.annotations.urls")),
] path("api/reader/", include("apps.reader.urls")),
]
if settings.DEBUG:
from django.conf.urls.static import static
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+25
View File
@@ -0,0 +1,25 @@
[project]
name = "backend"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"coverage==7.6.10",
"django==5.1.7",
"django-cors-headers==4.6.0",
"django-filter==25.1",
"djangorestframework==3.15.2",
"djangorestframework-simplejwt==5.4.0",
"gunicorn==23.0.0",
"pillow>=11.0.0",
"psycopg2-binary==2.9.10",
"pydantic==2.10.5",
"pydantic-settings==2.7.1",
"pytest==8.3.4",
"pytest-cov==6.0.0",
"pytest-django==4.9.0",
"python-dotenv==1.0.1",
"httpx>=0.28.0",
"ebooklib>=0.18",
"beautifulsoup4>=4.12.0",
"pypdf>=5.0.0",
]
+1
View File
@@ -8,6 +8,7 @@ pydantic==2.10.5
pydantic-settings==2.7.1 pydantic-settings==2.7.1
python-dotenv==1.0.1 python-dotenv==1.0.1
gunicorn==23.0.0 gunicorn==23.0.0
Pillow>=11.0.0
pytest==8.3.4 pytest==8.3.4
pytest-django==4.9.0 pytest-django==4.9.0
pytest-cov==6.0.0 pytest-cov==6.0.0
+673
View File
@@ -0,0 +1,673 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "asgiref"
version = "3.11.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
]
[[package]]
name = "backend"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "beautifulsoup4" },
{ name = "coverage" },
{ name = "django" },
{ name = "django-cors-headers" },
{ name = "django-filter" },
{ name = "djangorestframework" },
{ name = "djangorestframework-simplejwt" },
{ name = "ebooklib" },
{ name = "gunicorn" },
{ name = "httpx" },
{ name = "pillow" },
{ name = "psycopg2-binary" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pypdf" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "pytest-django" },
{ name = "python-dotenv" },
]
[package.metadata]
requires-dist = [
{ name = "beautifulsoup4", specifier = ">=4.12.0" },
{ name = "coverage", specifier = "==7.6.10" },
{ name = "django", specifier = "==5.1.7" },
{ name = "django-cors-headers", specifier = "==4.6.0" },
{ name = "django-filter", specifier = "==25.1" },
{ name = "djangorestframework", specifier = "==3.15.2" },
{ name = "djangorestframework-simplejwt", specifier = "==5.4.0" },
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gunicorn", specifier = "==23.0.0" },
{ name = "httpx", specifier = ">=0.28.0" },
{ name = "pillow", specifier = ">=11.0.0" },
{ name = "psycopg2-binary", specifier = "==2.9.10" },
{ name = "pydantic", specifier = "==2.10.5" },
{ name = "pydantic-settings", specifier = "==2.7.1" },
{ name = "pypdf", specifier = ">=5.0.0" },
{ name = "pytest", specifier = "==8.3.4" },
{ name = "pytest-cov", specifier = "==6.0.0" },
{ name = "pytest-django", specifier = "==4.9.0" },
{ name = "python-dotenv", specifier = "==1.0.1" },
]
[[package]]
name = "beautifulsoup4"
version = "4.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
]
[[package]]
name = "certifi"
version = "2026.5.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.6.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/84/ba/ac14d281f80aab516275012e8875991bb06203957aa1e19950139238d658/coverage-7.6.10.tar.gz", hash = "sha256:7fb105327c8f8f0682e29843e2ff96af9dcbe5bab8eeb4b398c6a33a16d80a23", size = 803868, upload-time = "2024-12-26T16:59:18.734Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/86/77/19d09ea06f92fdf0487499283b1b7af06bc422ea94534c8fe3a4cd023641/coverage-7.6.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27c6e64726b307782fa5cbe531e7647aee385a29b2107cd87ba7c0105a5d3853", size = 208281, upload-time = "2024-12-26T16:57:42.968Z" },
{ url = "https://files.pythonhosted.org/packages/b6/67/5479b9f2f99fcfb49c0d5cf61912a5255ef80b6e80a3cddba39c38146cf4/coverage-7.6.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c56e097019e72c373bae32d946ecf9858fda841e48d82df7e81c63ac25554078", size = 208514, upload-time = "2024-12-26T16:57:45.747Z" },
{ url = "https://files.pythonhosted.org/packages/15/d1/febf59030ce1c83b7331c3546d7317e5120c5966471727aa7ac157729c4b/coverage-7.6.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7827a5bc7bdb197b9e066cdf650b2887597ad124dd99777332776f7b7c7d0d0", size = 241537, upload-time = "2024-12-26T16:57:48.647Z" },
{ url = "https://files.pythonhosted.org/packages/4b/7e/5ac4c90192130e7cf8b63153fe620c8bfd9068f89a6d9b5f26f1550f7a26/coverage-7.6.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204a8238afe787323a8b47d8be4df89772d5c1e4651b9ffa808552bdf20e1d50", size = 238572, upload-time = "2024-12-26T16:57:51.668Z" },
{ url = "https://files.pythonhosted.org/packages/dc/03/0334a79b26ecf59958f2fe9dd1f5ab3e2f88db876f5071933de39af09647/coverage-7.6.10-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67926f51821b8e9deb6426ff3164870976fe414d033ad90ea75e7ed0c2e5022", size = 240639, upload-time = "2024-12-26T16:57:53.175Z" },
{ url = "https://files.pythonhosted.org/packages/d7/45/8a707f23c202208d7b286d78ad6233f50dcf929319b664b6cc18a03c1aae/coverage-7.6.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e78b270eadb5702938c3dbe9367f878249b5ef9a2fcc5360ac7bff694310d17b", size = 240072, upload-time = "2024-12-26T16:57:56.087Z" },
{ url = "https://files.pythonhosted.org/packages/66/02/603ce0ac2d02bc7b393279ef618940b4a0535b0868ee791140bda9ecfa40/coverage-7.6.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:714f942b9c15c3a7a5fe6876ce30af831c2ad4ce902410b7466b662358c852c0", size = 238386, upload-time = "2024-12-26T16:57:57.572Z" },
{ url = "https://files.pythonhosted.org/packages/04/62/4e6887e9be060f5d18f1dd58c2838b2d9646faf353232dec4e2d4b1c8644/coverage-7.6.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:abb02e2f5a3187b2ac4cd46b8ced85a0858230b577ccb2c62c81482ca7d18852", size = 240054, upload-time = "2024-12-26T16:57:58.967Z" },
{ url = "https://files.pythonhosted.org/packages/5c/74/83ae4151c170d8bd071924f212add22a0e62a7fe2b149edf016aeecad17c/coverage-7.6.10-cp312-cp312-win32.whl", hash = "sha256:55b201b97286cf61f5e76063f9e2a1d8d2972fc2fcfd2c1272530172fd28c359", size = 210904, upload-time = "2024-12-26T16:58:00.688Z" },
{ url = "https://files.pythonhosted.org/packages/c3/54/de0893186a221478f5880283119fc40483bc460b27c4c71d1b8bba3474b9/coverage-7.6.10-cp312-cp312-win_amd64.whl", hash = "sha256:e4ae5ac5e0d1e4edfc9b4b57b4cbecd5bc266a6915c500f358817a8496739247", size = 211692, upload-time = "2024-12-26T16:58:02.35Z" },
{ url = "https://files.pythonhosted.org/packages/25/6d/31883d78865529257bf847df5789e2ae80e99de8a460c3453dbfbe0db069/coverage-7.6.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05fca8ba6a87aabdd2d30d0b6c838b50510b56cdcfc604d40760dae7153b73d9", size = 208308, upload-time = "2024-12-26T16:58:04.487Z" },
{ url = "https://files.pythonhosted.org/packages/70/22/3f2b129cc08de00c83b0ad6252e034320946abfc3e4235c009e57cfeee05/coverage-7.6.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9e80eba8801c386f72e0712a0453431259c45c3249f0009aff537a517b52942b", size = 208565, upload-time = "2024-12-26T16:58:06.774Z" },
{ url = "https://files.pythonhosted.org/packages/97/0a/d89bc2d1cc61d3a8dfe9e9d75217b2be85f6c73ebf1b9e3c2f4e797f4531/coverage-7.6.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a372c89c939d57abe09e08c0578c1d212e7a678135d53aa16eec4430adc5e690", size = 241083, upload-time = "2024-12-26T16:58:10.27Z" },
{ url = "https://files.pythonhosted.org/packages/4c/81/6d64b88a00c7a7aaed3a657b8eaa0931f37a6395fcef61e53ff742b49c97/coverage-7.6.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec22b5e7fe7a0fa8509181c4aac1db48f3dd4d3a566131b313d1efc102892c18", size = 238235, upload-time = "2024-12-26T16:58:12.497Z" },
{ url = "https://files.pythonhosted.org/packages/9a/0b/7797d4193f5adb4b837207ed87fecf5fc38f7cc612b369a8e8e12d9fa114/coverage-7.6.10-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26bcf5c4df41cad1b19c84af71c22cbc9ea9a547fc973f1f2cc9a290002c8b3c", size = 240220, upload-time = "2024-12-26T16:58:15.619Z" },
{ url = "https://files.pythonhosted.org/packages/65/4d/6f83ca1bddcf8e51bf8ff71572f39a1c73c34cf50e752a952c34f24d0a60/coverage-7.6.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e4630c26b6084c9b3cb53b15bd488f30ceb50b73c35c5ad7871b869cb7365fd", size = 239847, upload-time = "2024-12-26T16:58:17.126Z" },
{ url = "https://files.pythonhosted.org/packages/30/9d/2470df6aa146aff4c65fee0f87f58d2164a67533c771c9cc12ffcdb865d5/coverage-7.6.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2396e8116db77789f819d2bc8a7e200232b7a282c66e0ae2d2cd84581a89757e", size = 237922, upload-time = "2024-12-26T16:58:20.198Z" },
{ url = "https://files.pythonhosted.org/packages/08/dd/723fef5d901e6a89f2507094db66c091449c8ba03272861eaefa773ad95c/coverage-7.6.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79109c70cc0882e4d2d002fe69a24aa504dec0cc17169b3c7f41a1d341a73694", size = 239783, upload-time = "2024-12-26T16:58:23.614Z" },
{ url = "https://files.pythonhosted.org/packages/3d/f7/64d3298b2baf261cb35466000628706ce20a82d42faf9b771af447cd2b76/coverage-7.6.10-cp313-cp313-win32.whl", hash = "sha256:9e1747bab246d6ff2c4f28b4d186b205adced9f7bd9dc362051cc37c4a0c7bd6", size = 210965, upload-time = "2024-12-26T16:58:26.765Z" },
{ url = "https://files.pythonhosted.org/packages/d5/58/ec43499a7fc681212fe7742fe90b2bc361cdb72e3181ace1604247a5b24d/coverage-7.6.10-cp313-cp313-win_amd64.whl", hash = "sha256:254f1a3b1eef5f7ed23ef265eaa89c65c8c5b6b257327c149db1ca9d4a35f25e", size = 211719, upload-time = "2024-12-26T16:58:28.781Z" },
{ url = "https://files.pythonhosted.org/packages/ab/c9/f2857a135bcff4330c1e90e7d03446b036b2363d4ad37eb5e3a47bbac8a6/coverage-7.6.10-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2ccf240eb719789cedbb9fd1338055de2761088202a9a0b73032857e53f612fe", size = 209050, upload-time = "2024-12-26T16:58:31.616Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b3/f840e5bd777d8433caa9e4a1eb20503495709f697341ac1a8ee6a3c906ad/coverage-7.6.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0c807ca74d5a5e64427c8805de15b9ca140bba13572d6d74e262f46f50b13273", size = 209321, upload-time = "2024-12-26T16:58:34.509Z" },
{ url = "https://files.pythonhosted.org/packages/85/7d/125a5362180fcc1c03d91850fc020f3831d5cda09319522bcfa6b2b70be7/coverage-7.6.10-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bcfa46d7709b5a7ffe089075799b902020b62e7ee56ebaed2f4bdac04c508d8", size = 252039, upload-time = "2024-12-26T16:58:36.072Z" },
{ url = "https://files.pythonhosted.org/packages/a9/9c/4358bf3c74baf1f9bddd2baf3756b54c07f2cfd2535f0a47f1e7757e54b3/coverage-7.6.10-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e0de1e902669dccbf80b0415fb6b43d27edca2fbd48c74da378923b05316098", size = 247758, upload-time = "2024-12-26T16:58:39.458Z" },
{ url = "https://files.pythonhosted.org/packages/cf/c7/de3eb6fc5263b26fab5cda3de7a0f80e317597a4bad4781859f72885f300/coverage-7.6.10-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7b444c42bbc533aaae6b5a2166fd1a797cdb5eb58ee51a92bee1eb94a1e1cb", size = 250119, upload-time = "2024-12-26T16:58:41.018Z" },
{ url = "https://files.pythonhosted.org/packages/3e/e6/43de91f8ba2ec9140c6a4af1102141712949903dc732cf739167cfa7a3bc/coverage-7.6.10-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b330368cb99ef72fcd2dc3ed260adf67b31499584dc8a20225e85bfe6f6cfed0", size = 249597, upload-time = "2024-12-26T16:58:42.827Z" },
{ url = "https://files.pythonhosted.org/packages/08/40/61158b5499aa2adf9e37bc6d0117e8f6788625b283d51e7e0c53cf340530/coverage-7.6.10-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9a7cfb50515f87f7ed30bc882f68812fd98bc2852957df69f3003d22a2aa0abf", size = 247473, upload-time = "2024-12-26T16:58:44.486Z" },
{ url = "https://files.pythonhosted.org/packages/50/69/b3f2416725621e9f112e74e8470793d5b5995f146f596f133678a633b77e/coverage-7.6.10-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f93531882a5f68c28090f901b1d135de61b56331bba82028489bc51bdd818d2", size = 248737, upload-time = "2024-12-26T16:58:45.919Z" },
{ url = "https://files.pythonhosted.org/packages/3c/6e/fe899fb937657db6df31cc3e61c6968cb56d36d7326361847440a430152e/coverage-7.6.10-cp313-cp313t-win32.whl", hash = "sha256:89d76815a26197c858f53c7f6a656686ec392b25991f9e409bcef020cd532312", size = 211611, upload-time = "2024-12-26T16:58:47.883Z" },
{ url = "https://files.pythonhosted.org/packages/1c/55/52f5e66142a9d7bc93a15192eba7a78513d2abf6b3558d77b4ca32f5f424/coverage-7.6.10-cp313-cp313t-win_amd64.whl", hash = "sha256:54a5f0f43950a36312155dae55c505a76cd7f2b12d26abeebbe7a0b36dbc868d", size = 212781, upload-time = "2024-12-26T16:58:50.822Z" },
]
[[package]]
name = "django"
version = "5.1.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5f/57/11186e493ddc5a5e92cc7924a6363f7d4c2b645f7d7cb04a26a63f9bfb8b/Django-5.1.7.tar.gz", hash = "sha256:30de4ee43a98e5d3da36a9002f287ff400b43ca51791920bfb35f6917bfe041c", size = 10716510, upload-time = "2025-03-06T12:52:18.938Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/0f/7e042df3d462d39ae01b27a09ee76653692442bc3701fbfa6cb38e12889d/Django-5.1.7-py3-none-any.whl", hash = "sha256:1323617cb624add820cb9611cdcc788312d250824f92ca6048fda8625514af2b", size = 8276912, upload-time = "2025-03-06T12:52:12.784Z" },
]
[[package]]
name = "django-cors-headers"
version = "4.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/e5/3b67fc05b9c02b926411436dfc553829bc00843706ce7f99752433017f47/django_cors_headers-4.6.0.tar.gz", hash = "sha256:14d76b4b4c8d39375baeddd89e4f08899051eeaf177cb02a29bd6eae8cf63aa8", size = 20961, upload-time = "2024-10-29T10:38:15.281Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/73/689532cf164ab10ed1521d825ea156656520cec98886c8d2ac1ce8829220/django_cors_headers-4.6.0-py3-none-any.whl", hash = "sha256:8edbc0497e611c24d5150e0055d3b178c6534b8ed826fb6f53b21c63f5d48ba3", size = 12791, upload-time = "2024-10-29T10:38:13.784Z" },
]
[[package]]
name = "django-filter"
version = "25.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b5/40/c702a6fe8cccac9bf426b55724ebdf57d10a132bae80a17691d0cf0b9bac/django_filter-25.1.tar.gz", hash = "sha256:1ec9eef48fa8da1c0ac9b411744b16c3f4c31176c867886e4c48da369c407153", size = 143021, upload-time = "2025-02-14T16:30:53.238Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/a6/70dcd68537c434ba7cb9277d403c5c829caf04f35baf5eb9458be251e382/django_filter-25.1-py3-none-any.whl", hash = "sha256:4fa48677cf5857b9b1347fed23e355ea792464e0fe07244d1fdfb8a806215b80", size = 94114, upload-time = "2025-02-14T16:30:50.435Z" },
]
[[package]]
name = "djangorestframework"
version = "3.15.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2c/ce/31482eb688bdb4e271027076199e1aa8d02507e530b6d272ab8b4481557c/djangorestframework-3.15.2.tar.gz", hash = "sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad", size = 1067420, upload-time = "2024-06-19T07:59:32.891Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/b6/fa99d8f05eff3a9310286ae84c4059b08c301ae4ab33ae32e46e8ef76491/djangorestframework-3.15.2-py3-none-any.whl", hash = "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20", size = 1071235, upload-time = "2024-06-19T07:59:26.106Z" },
]
[[package]]
name = "djangorestframework-simplejwt"
version = "5.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "djangorestframework" },
{ name = "pyjwt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8d/5f/1c130e823f734ba776c3925ad8e7c28ca1a59feb22d039b3810d1f8c0b34/djangorestframework_simplejwt-5.4.0.tar.gz", hash = "sha256:cccecce1a0e1a4a240fae80da73e5fc23055bababb8b67de88fa47cd36822320", size = 96648, upload-time = "2025-01-07T08:25:11.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/41/d6f67d24f46c7d8ee6dcb85c9abb94bad23140210bfb8c454641d278dfd2/djangorestframework_simplejwt-5.4.0-py3-none-any.whl", hash = "sha256:7aec953db9ed4163430c16d086eecb0f028f814ce6bba62b06c25919261e9077", size = 102316, upload-time = "2025-01-07T08:24:30.503Z" },
]
[[package]]
name = "ebooklib"
version = "0.20"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "lxml" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/77/85/322e8882a582d4b707220d1929cfb74c125f2ba513991edbce40dbc462de/ebooklib-0.20.tar.gz", hash = "sha256:35e2f9d7d39907be8d39ae2deb261b19848945903ae3dbb6577b187ead69e985", size = 127066, upload-time = "2025-10-26T20:56:20.968Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/ee/aa015c5de8b0dc42a8e507eae8c2de5d1c0e068c896858fec6d502402ed6/ebooklib-0.20-py3-none-any.whl", hash = "sha256:fff5322517a37e31c972d27be7d982cc3928c16b3dcc5fd7e8f7c0f5d7bcf42b", size = 40995, upload-time = "2025-10-26T20:56:19.104Z" },
]
[[package]]
name = "gunicorn"
version = "23.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "lxml"
version = "6.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" },
{ url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" },
{ url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" },
{ url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" },
{ url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" },
{ url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" },
{ url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" },
{ url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" },
{ url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" },
{ url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" },
{ url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" },
{ url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" },
{ url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" },
{ url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" },
{ url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" },
{ url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" },
{ url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" },
{ url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" },
{ url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" },
{ url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" },
{ url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" },
{ url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" },
{ url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" },
{ url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" },
{ url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" },
{ url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" },
{ url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" },
{ url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" },
{ url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" },
{ url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" },
{ url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" },
{ url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" },
{ url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" },
{ url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" },
{ url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" },
{ url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" },
{ url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" },
{ url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" },
{ url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" },
{ url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" },
{ url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" },
{ url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" },
{ url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" },
{ url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" },
{ url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" },
{ url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" },
{ url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" },
{ url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" },
{ url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" },
{ url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" },
{ url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" },
{ url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" },
{ url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" },
{ url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" },
{ url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" },
{ url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" },
{ url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" },
{ url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" },
{ url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" },
{ url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" },
{ url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" },
{ url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" },
{ url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" },
{ url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" },
{ url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" },
{ url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" },
{ url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" },
{ url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" },
{ url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pillow"
version = "12.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
{ url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
{ url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
{ url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
{ url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
{ url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
{ url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
{ url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
{ url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
{ url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
{ url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
{ url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
{ url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
{ url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
{ url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
{ url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
{ url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
{ url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
{ url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
{ url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
{ url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
{ url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
{ url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
{ url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
{ url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
{ url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
{ url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
{ url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
{ url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
{ url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
{ url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
{ url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
{ url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "psycopg2-binary"
version = "2.9.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", size = 385764, upload-time = "2024-10-16T11:24:58.126Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/7d/465cc9795cf76f6d329efdafca74693714556ea3891813701ac1fee87545/psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0", size = 3044771, upload-time = "2024-10-16T11:20:35.234Z" },
{ url = "https://files.pythonhosted.org/packages/8b/31/6d225b7b641a1a2148e3ed65e1aa74fc86ba3fee850545e27be9e1de893d/psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a", size = 3275336, upload-time = "2024-10-16T11:20:38.742Z" },
{ url = "https://files.pythonhosted.org/packages/30/b7/a68c2b4bff1cbb1728e3ec864b2d92327c77ad52edcd27922535a8366f68/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539", size = 2851637, upload-time = "2024-10-16T11:20:42.145Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b1/cfedc0e0e6f9ad61f8657fd173b2f831ce261c02a08c0b09c652b127d813/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526", size = 3082097, upload-time = "2024-10-16T11:20:46.185Z" },
{ url = "https://files.pythonhosted.org/packages/18/ed/0a8e4153c9b769f59c02fb5e7914f20f0b2483a19dae7bf2db54b743d0d0/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1", size = 3264776, upload-time = "2024-10-16T11:20:50.879Z" },
{ url = "https://files.pythonhosted.org/packages/10/db/d09da68c6a0cdab41566b74e0a6068a425f077169bed0946559b7348ebe9/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e", size = 3020968, upload-time = "2024-10-16T11:20:56.819Z" },
{ url = "https://files.pythonhosted.org/packages/94/28/4d6f8c255f0dfffb410db2b3f9ac5218d959a66c715c34cac31081e19b95/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f", size = 2872334, upload-time = "2024-10-16T11:21:02.411Z" },
{ url = "https://files.pythonhosted.org/packages/05/f7/20d7bf796593c4fea95e12119d6cc384ff1f6141a24fbb7df5a668d29d29/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00", size = 2822722, upload-time = "2024-10-16T11:21:09.01Z" },
{ url = "https://files.pythonhosted.org/packages/4d/e4/0c407ae919ef626dbdb32835a03b6737013c3cc7240169843965cada2bdf/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5", size = 2920132, upload-time = "2024-10-16T11:21:16.339Z" },
{ url = "https://files.pythonhosted.org/packages/2d/70/aa69c9f69cf09a01da224909ff6ce8b68faeef476f00f7ec377e8f03be70/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47", size = 2959312, upload-time = "2024-10-16T11:21:25.584Z" },
{ url = "https://files.pythonhosted.org/packages/d3/bd/213e59854fafe87ba47814bf413ace0dcee33a89c8c8c814faca6bc7cf3c/psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64", size = 1025191, upload-time = "2024-10-16T11:21:29.912Z" },
{ url = "https://files.pythonhosted.org/packages/92/29/06261ea000e2dc1e22907dbbc483a1093665509ea586b29b8986a0e56733/psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0", size = 1164031, upload-time = "2024-10-16T11:21:34.211Z" },
{ url = "https://files.pythonhosted.org/packages/3e/30/d41d3ba765609c0763505d565c4d12d8f3c79793f0d0f044ff5a28bf395b/psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", size = 3044699, upload-time = "2024-10-16T11:21:42.841Z" },
{ url = "https://files.pythonhosted.org/packages/35/44/257ddadec7ef04536ba71af6bc6a75ec05c5343004a7ec93006bee66c0bc/psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", size = 3275245, upload-time = "2024-10-16T11:21:51.989Z" },
{ url = "https://files.pythonhosted.org/packages/1b/11/48ea1cd11de67f9efd7262085588790a95d9dfcd9b8a687d46caf7305c1a/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", size = 2851631, upload-time = "2024-10-16T11:21:57.584Z" },
{ url = "https://files.pythonhosted.org/packages/62/e0/62ce5ee650e6c86719d621a761fe4bc846ab9eff8c1f12b1ed5741bf1c9b/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", size = 3082140, upload-time = "2024-10-16T11:22:02.005Z" },
{ url = "https://files.pythonhosted.org/packages/27/ce/63f946c098611f7be234c0dd7cb1ad68b0b5744d34f68062bb3c5aa510c8/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", size = 3264762, upload-time = "2024-10-16T11:22:06.412Z" },
{ url = "https://files.pythonhosted.org/packages/43/25/c603cd81402e69edf7daa59b1602bd41eb9859e2824b8c0855d748366ac9/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", size = 3020967, upload-time = "2024-10-16T11:22:11.583Z" },
{ url = "https://files.pythonhosted.org/packages/5f/d6/8708d8c6fca531057fa170cdde8df870e8b6a9b136e82b361c65e42b841e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", size = 2872326, upload-time = "2024-10-16T11:22:16.406Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", size = 2822712, upload-time = "2024-10-16T11:22:21.366Z" },
{ url = "https://files.pythonhosted.org/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", size = 2920155, upload-time = "2024-10-16T11:22:25.684Z" },
{ url = "https://files.pythonhosted.org/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", size = 2959356, upload-time = "2024-10-16T11:22:30.562Z" },
{ url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224, upload-time = "2025-01-04T20:09:19.234Z" },
]
[[package]]
name = "pydantic"
version = "2.10.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6a/c7/ca334c2ef6f2e046b1144fe4bb2a5da8a4c574e7f2ebf7e16b34a6a2fa92/pydantic-2.10.5.tar.gz", hash = "sha256:278b38dbbaec562011d659ee05f63346951b3a248a6f3642e1bc68894ea2b4ff", size = 761287, upload-time = "2025-01-09T13:33:25.929Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/26/82663c79010b28eddf29dcdd0ea723439535fa917fce5905885c0e9ba562/pydantic-2.10.5-py3-none-any.whl", hash = "sha256:4dd4e322dbe55472cb7ca7e73f4b63574eecccf2835ffa2af9021ce113c83c53", size = 431426, upload-time = "2025-01-09T13:33:22.312Z" },
]
[[package]]
name = "pydantic-core"
version = "2.27.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443, upload-time = "2024-12-18T11:31:54.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127, upload-time = "2024-12-18T11:28:30.346Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340, upload-time = "2024-12-18T11:28:32.521Z" },
{ url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900, upload-time = "2024-12-18T11:28:34.507Z" },
{ url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177, upload-time = "2024-12-18T11:28:36.488Z" },
{ url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046, upload-time = "2024-12-18T11:28:39.409Z" },
{ url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386, upload-time = "2024-12-18T11:28:41.221Z" },
{ url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060, upload-time = "2024-12-18T11:28:44.709Z" },
{ url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870, upload-time = "2024-12-18T11:28:46.839Z" },
{ url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822, upload-time = "2024-12-18T11:28:48.896Z" },
{ url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364, upload-time = "2024-12-18T11:28:50.755Z" },
{ url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303, upload-time = "2024-12-18T11:28:54.122Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064, upload-time = "2024-12-18T11:28:56.074Z" },
{ url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046, upload-time = "2024-12-18T11:28:58.107Z" },
{ url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092, upload-time = "2024-12-18T11:29:01.335Z" },
{ url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709, upload-time = "2024-12-18T11:29:03.193Z" },
{ url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273, upload-time = "2024-12-18T11:29:05.306Z" },
{ url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027, upload-time = "2024-12-18T11:29:07.294Z" },
{ url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888, upload-time = "2024-12-18T11:29:09.249Z" },
{ url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738, upload-time = "2024-12-18T11:29:11.23Z" },
{ url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138, upload-time = "2024-12-18T11:29:16.396Z" },
{ url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025, upload-time = "2024-12-18T11:29:20.25Z" },
{ url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633, upload-time = "2024-12-18T11:29:23.877Z" },
{ url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404, upload-time = "2024-12-18T11:29:25.872Z" },
{ url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130, upload-time = "2024-12-18T11:29:29.252Z" },
{ url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946, upload-time = "2024-12-18T11:29:31.338Z" },
{ url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387, upload-time = "2024-12-18T11:29:33.481Z" },
{ url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453, upload-time = "2024-12-18T11:29:35.533Z" },
{ url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186, upload-time = "2024-12-18T11:29:37.649Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/73/7b/c58a586cd7d9ac66d2ee4ba60ca2d241fa837c02bca9bea80a9a8c3d22a9/pydantic_settings-2.7.1.tar.gz", hash = "sha256:10c9caad35e64bfb3c2fbf70a078c0e25cc92499782e5200747f942a065dec93", size = 79920, upload-time = "2024-12-31T11:27:44.632Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/46/93416fdae86d40879714f72956ac14df9c7b76f7d41a4d68aa9f71a0028b/pydantic_settings-2.7.1-py3-none-any.whl", hash = "sha256:590be9e6e24d06db33a4262829edef682500ef008565a969c73d39d5f8bfb3fd", size = 29718, upload-time = "2024-12-31T11:27:43.201Z" },
]
[[package]]
name = "pyjwt"
version = "2.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
]
[[package]]
name = "pypdf"
version = "6.12.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0a/6d/20879428577c1e57ecd41b69dc86beabf43db9287ad2e702207f8b48c751/pypdf-6.12.2.tar.gz", hash = "sha256:111669eb6680c04495ae0c113a1476e3bf93a95761d23c7406b591c80a6490b1", size = 6468184, upload-time = "2026-05-26T13:31:26.911Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/44/fee070a16639d9869bb6a7e0f3a1b3946da1d66f32b9260b4d19cb90d7b2/pypdf-6.12.2-py3-none-any.whl", hash = "sha256:67b2699357a1f3f4c945940ea80826349ee507c9e2577724a14b4941982c104d", size = 343865, upload-time = "2026-05-26T13:31:25.068Z" },
]
[[package]]
name = "pytest"
version = "8.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919, upload-time = "2024-12-01T12:54:25.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083, upload-time = "2024-12-01T12:54:19.735Z" },
]
[[package]]
name = "pytest-cov"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945, upload-time = "2024-10-29T20:13:35.363Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949, upload-time = "2024-10-29T20:13:33.215Z" },
]
[[package]]
name = "pytest-django"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/c0/43c8b2528c24d7f1a48a47e3f7381f5ab2ae8c64634b0c3f4bd843063955/pytest_django-4.9.0.tar.gz", hash = "sha256:8bf7bc358c9ae6f6fc51b6cebb190fe20212196e6807121f11bd6a3b03428314", size = 84067, upload-time = "2024-09-02T15:49:18.407Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/fe/54f387ee1b41c9ad59e48fb8368a361fad0600fe404315e31a12bacaea7d/pytest_django-4.9.0-py3-none-any.whl", hash = "sha256:1d83692cb39188682dbb419ff0393867e9904094a549a7d38a3154d5731b2b99", size = 23723, upload-time = "2024-09-02T15:49:17.127Z" },
]
[[package]]
name = "python-dotenv"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "soupsieve"
version = "2.8.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
]
[[package]]
name = "sqlparse"
version = "0.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "tzdata"
version = "2026.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
]
+24 -19
View File
@@ -5,48 +5,53 @@ services:
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
environment: environment:
POSTGRES_DB: cloudreader POSTGRES_DB: ${POSTGRES_DB:-cloud_reader}
POSTGRES_USER: cloudreader POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: cloudreader POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
ports: ports:
- "5432:5432" - "5432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U cloudreader -d cloudreader"] test: ["CMD-SHELL", "pg_isready -U postgres -d cloud_reader"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
api: backend:
build: build:
context: ./api context: ./backend
dockerfile: Dockerfile
ports: ports:
- "8000:8000" - "8000:8000"
environment: environment:
DATABASE_URL: "postgres://cloudreader:cloudreader@db:5432/cloudreader" DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
DJANGO_SECRET_KEY: "dev-secret-key-change-in-production" DJANGO_DEBUG: ${DJANGO_DEBUG:-True}
DJANGO_DEBUG: "True" DB_NAME: ${POSTGRES_DB:-cloud_reader}
DB_USER: ${POSTGRES_USER:-postgres}
DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
DB_HOST: ${DB_HOST:-db}
DB_PORT: ${DB_PORT:-5432}
volumes: volumes:
- ./api:/app - ./backend:/app
- book_uploads:/app/media - ./backend/media:/app/media
command: > command: >
sh -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000" sh -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
web: frontend:
build: build:
context: ./web context: ./frontend
dockerfile: Dockerfile
ports: ports:
- "3000:3000" - "5173:5173"
environment: environment:
VITE_API_URL: "http://localhost:8000" VITE_API_URL: ${VITE_API_URL:-http://localhost:8000}
volumes: volumes:
- ./web:/app - ./frontend:/app
- /app/node_modules - /app/node_modules
depends_on: depends_on:
- api - backend
volumes: volumes:
postgres_data: postgres_data:
book_uploads:
+117
View File
@@ -0,0 +1,117 @@
# 010 — Open Library metadata on import
**Status:** Implemented
**Created:** 2026-06-03
## Objective
After a user uploads an EPUB/PDF (`POST /api/books/ebooks/`), enrich the `EBook` with metadata and a cover from [Open Library](https://openlibrary.org/developers/api), using only **title** and **author** from the upload form. Prefer **Spanish** editions when available; fall back to English/any.
Upload must **never fail** if Open Library is down or no match is found.
## Trigger
- **Automatic:** `EBookUploadSerializer.create()` calls `enrich_ebook_metadata(ebook)` after file save.
- **Manual:** `POST /api/books/ebooks/{id}/enrich-metadata/` re-runs enrichment (owner only).
## Open Library usage
### Search
`GET https://openlibrary.org/search.json`
| Param | Value |
|-------|--------|
| `title` | User-provided title |
| `author` | User-provided author |
| `lang` | `es` (primary) or `en` (fallback) |
| `limit` | `5` |
| `fields` | `key,title,author_name,cover_i,first_publish_year,subject,language,edition_key,number_of_pages_median,publisher` |
Primary pass also uses query filter `language:spa`. Fallback omits language filter.
### Covers
`GET https://covers.openlibrary.org/b/id/{cover_i}-L.jpg` — downloaded and stored on `EBook.cover_image`.
## Match scoring
| Score | Behavior |
|-------|----------|
| ≥ 0.8 | Apply OL title/author + cover + full metadata |
| 0.6 0.8 | Metadata + cover only; keep user title/author |
| < 0.6 | `match_status: not_found`; no field changes except metadata stub |
Author overlap + title similarity (normalized strings, `difflib.SequenceMatcher`). Prefer hits with `cover_i`.
## Data model
No migration. Uses existing fields on `EBook`:
- `metadata_json` — full enrichment payload (see below)
- `cover_image` — downloaded cover file
- `title` / `author` — updated when match score ≥ 0.8
### `metadata_json` shape
```json
{
"source": "openlibrary",
"matched_at": "2026-06-03T12:00:00+00:00",
"match_language": "es",
"match_score": 0.92,
"match_status": "matched",
"user_input": { "title": "...", "author": "..." },
"openlibrary": {
"work_key": "/works/OL...",
"edition_key": "...",
"title": "...",
"authors": ["..."],
"cover_id": 12345,
"cover_url": "https://covers.openlibrary.org/b/id/12345-L.jpg",
"first_publish_year": 1605,
"subjects": ["..."],
"languages": ["spa"],
"publishers": ["..."],
"number_of_pages_median": 320
}
}
```
## API changes
### Upload response (unchanged path)
`POST /api/books/ebooks/` — response may include populated `cover_image` and updated `title`/`author` after sync enrichment.
### Detail
`GET /api/books/ebooks/{id}/` — adds read-only `metadata` (alias of `metadata_json`).
### Manual refresh
`POST /api/books/ebooks/{id}/enrich-metadata/` — returns updated `EBookDetailSerializer` payload.
## Configuration
| Env var | Default | Description |
|---------|---------|-------------|
| `OPENLIBRARY_ENABLED` | `true` | Kill switch |
| `OPENLIBRARY_PREFERRED_LANG` | `es` | Primary `lang` param |
| `OPENLIBRARY_FALLBACK_LANG` | `en` | Fallback `lang` param |
| `OPENLIBRARY_TIMEOUT_SECONDS` | `5` | HTTP timeout |
| `OPENLIBRARY_USER_AGENT` | `CloudReader/1.0` | User-Agent header |
## Code layout
```
backend/apps/books/services/
├── openlibrary.py # Search, scoring, cover download
└── metadata.py # enrich_ebook_metadata orchestrator
```
## Verification
1. Upload with title `Don Quijote`, author `Cervantes` → cover + Spanish-friendly metadata.
2. Upload with nonsense title/author → 201, no cover, `match_status: not_found`.
3. `POST .../enrich-metadata/` on existing ebook refreshes metadata.
+107
View File
@@ -0,0 +1,107 @@
# Book Search & Discovery — Spec
## Overview
Enable users to search books within the library and discover new books via filters and a dedicated detail view.
## Backend API Contracts
### Book List & Search
`GET /api/books/`
**Query Parameters:**
| Param | Type | Description |
|-------|------|-------------|
| `q` | string | Full-text search across title, author, genre |
| `genre` | string | Exact filter by genre |
| `author` | string | Exact filter by author |
| `reading_status` | string | Filter: `want_to_read`, `reading`, `finished`, `dnf` |
| `ordering` | string | `title`, `author`, `genre`, `created_at` (prefix `-` for desc) |
| `page` | int | Page number (default: 1) |
**Response (paginated):**
```json
{
"count": 42,
"next": "http://.../?page=2",
"previous": null,
"results": [
{
"id": 1,
"title": "Dune",
"author": "Frank Herbert",
"genre": "Science Fiction",
"reading_status": "finished",
"reading_status_display": "Finished",
"cover_image": "https://..."
}
]
}
```
### Book Detail
`GET /api/books/{id}/`
**Response:**
```json
{
"id": 1,
"title": "Dune",
"author": "Frank Herbert",
"genre": "Science Fiction",
"description": "...",
"reading_status": "finished",
"reading_status_display": "Finished",
"cover_image": "https://...",
"total_pages": 688,
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-15T00:00:00Z"
}
```
### Genre / Author Discovery
`GET /api/books/genres/``["Fiction", "Science Fiction", ...]`
`GET /api/books/authors/``["Frank Herbert", "Ursula K. Le Guin", ...]`
## Frontend Components
### LibraryPage (enhanced)
- **Search bar** at top: text input with debounced `onChange` → calls API with `q` param
- **Filter row**: genre dropdown, author dropdown, reading status dropdown
- Genre/Author dropdowns populated from `/api/books/genres/` and `/api/books/authors/`
- Reading status uses static enum values (`READING_STATUS_OPTIONS`)
- **Results grid**: card layout showing cover, title, author, reading status badge
- **Empty state**: "No books found" with clear message when results are empty
- **Loading state**: spinner/skeleton while fetching
- **Click card → navigate to** `/books/{id}`
### BookDetailPage (new)
- Shows full book info: cover, title, author, genre, description, reading status, total pages
- Back button to return to library
- Clean, mobile-responsive layout
### API Client — `frontend/src/api/books.ts`
| Method | Endpoint | Returns |
|--------|----------|---------|
| `searchBooks(params)` | `GET /api/books/` | `{count, results: BookListItem[]}` |
| `getBook(id)` | `GET /api/books/{id}/` | `BookDetail` |
| `getGenres()` | `GET /api/books/genres/` | `string[]` |
| `getAuthors()` | `GET /api/books/authors/` | `string[]` |
## Routes (Frontend)
| Path | Component | Auth |
|------|-----------|------|
| `/` | LibraryPage | Protected |
| `/books/:id` | BookDetailPage | Protected |
## Data Flow
1. User types in search bar → 300ms debounce → `GET /api/books/?q=...`
2. User selects filter → `GET /api/books/?genre=...&author=...&reading_status=...`
3. User clicks result → navigate to `/books/:id`
4. BookDetailPage → `GET /api/books/{id}/`
## Mobile Optimizations
- Filters collapse into a toggleable panel on small screens
- Cards stack in single column on mobile
- Touch-friendly tap targets (min 44px)
@@ -0,0 +1,246 @@
# US: Customizable Mobile Reading Experience
**Issue:** https://gitea-dev.codescripters.org/HermesFactory/cloud-reader/issues (TBD)
## Overview
Add a full-screen reading view for ebooks with customizable typography, themes,
table of contents navigation, and orientation support. Mobile-first, responsive
design that adapts to any screen size.
---
## Backend Specification
### New Models
#### `apps.books.models.Chapter`
| Field | Type | Notes |
|-------------|--------------------|--------------------------------|
| id | AutoField (PK) | |
| book | FK -> Book | related_name="chapters" |
| title | CharField(512) | Chapter title |
| number | PositiveIntegerField | Chapter ordering / TOC index |
| content | TextField | Chapter text/markdown content |
| created_at | DateTimeField | auto_now_add |
| updated_at | DateTimeField | auto_now |
**Constraints:** UniqueConstraint(book, chapter_number)
**Ordering:** [book, number]
**Index:** FK to book with db_index
#### `apps.books.models.ReadingProgress`
| Field | Type | Notes |
|------------------|--------------------|--------------------------------|
| id | AutoField (PK) | |
| user | FK -> User | related_name="reading_progress"|
| book | FK -> Book | related_name="reading_progress"|
| current_chapter | PositiveIntegerField | Last chapter number |
| current_position | PositiveIntegerField | Position within chapter (paragraph) |
| percentage | FloatField | 0.0 - 100.0 overall progress |
| updated_at | DateTimeField | auto_now |
**Constraints:** UniqueConstraint(user, book)
**Indexes:** (user, book) composite, (user) filter for list queries
#### `apps.reader.models.ReadingSettings`
New app `apps/reader/` for reading preferences, isolated from book data model.
| Field | Type | Notes |
|-------------------|--------------------|-------------------------------|
| id | AutoField (PK) | |
| user | OneToOneField -> User | related_name="reading_settings" |
| font_family | CharField(32) | "sans-serif", "serif", "monospace" |
| font_size | PositiveSmallIntegerField | 12-32, default 18 |
| line_height | FloatField | 1.2 - 2.0, default 1.6 |
| margin_width | PositiveSmallIntegerField | 8-48, default 16 (px) |
| background_color | CharField(7) | Hex color, default "#f5f0eb" |
| text_color | CharField(7) | Hex color, default "#1a1a1a" |
| brightness | PositiveSmallIntegerField | 0-100, default 100 |
| orientation_lock | CharField(16) | "auto", "portrait", "landscape" |
| theme | CharField(32) | "sepia", "dark", "light", "paper" |
| created_at | DateTimeField | auto_now_add |
| updated_at | DateTimeField | auto_now |
### New API Endpoints
All under `/api/` prefix, authenticated with JWT.
#### Reader Settings (`/api/reader/settings/`)
| Method | URL | Action |
|--------|------------------------------|---------------------------|
| GET | /api/reader/settings/ | Get current user settings |
| PUT | /api/reader/settings/ | Create/update settings |
| PATCH | /api/reader/settings/ | Partial update settings |
- Single-object endpoint (one settings record per user, auto-created on first GET)
- Validation: font_size 12-32, line_height 1.2-2.0, margin_width 8-48
#### Reading Progress (`/api/books/{id}/progress/`)
| Method | URL | Action |
|--------|----------------------------------------|------------------------------|
| GET | /api/books/{id}/progress/ | Get reading progress for book|
| PUT | /api/books/{id}/progress/ | Create/update reading progress|
- Nested under book detail
- Auto-creates progress record on first PUT
#### Chapters (`/api/books/{id}/chapters/`)
| Method | URL | Action |
|--------|----------------------------------------|------------------------------|
| GET | /api/books/{id}/chapters/ | List chapters for book (TOC) |
| GET | /api/books/{id}/chapters/{number}/ | Get specific chapter content |
- Ordering by `number`
- Used by frontend TOC sidebar and content loading
---
## Frontend Specification
### New Pages
#### `/reader/:bookId` — ReadingPage
Full-screen reading view with:
- Chapter content display (left/right swiping or scroll)
- Bottom toolbar: TOC toggle, Settings toggle, Progress indicator
- Top bar: Back button, Book title, Chapter title
- Swipe/tap/page navigation between chapters
### New Components
#### `ReaderToolbar`
- Fixed bottom toolbar
- TOC button (opens TOC drawer)
- Settings/theme button (opens settings panel)
- Progress bar showing overall reading progress
#### `TableOfContents`
- Slide-in drawer from left
- Lists all chapters with current chapter highlighted
- Tap on chapter to navigate
- Shows reading progress per chapter
#### `ReadingSettingsPanel`
- Slide-in drawer from right (or bottom sheet on mobile)
- Controls:
- Theme presets: Sepia, Dark, Light, Paper
- Font family: Sans-serif, Serif, Monospace
- Font size slider (12-32)
- Line height slider (1.2-2.0)
- Margin/padding control
- Orientation lock toggle (Auto / Portrait / Landscape)
- All changes persist immediately via API
- LocalStorage fallback when offline
### New Hooks
#### `useReadingSettings(bookId)`
- Fetches user reading settings from API
- Returns current settings + update function
- Applies CSS custom properties to document root
- Falls back to defaults if API unavailable
#### `useChapters(bookId)`
- Fetches chapter list for TOC
- Returns chapters array, current chapter, navigate function
- Prefetches next/prev chapter content
#### `useReadingProgress(bookId)`
- Fetches/updates reading progress
- Auto-saves position on chapter change and periodic interval
### New Types
```typescript
interface Chapter {
id: number;
book: number;
title: string;
number: number;
content?: string; // Only present when fetching individual chapter
}
interface ChapterSummary {
id: number;
book: number;
title: string;
number: number;
}
interface ReadingSettings {
font_family: "sans-serif" | "serif" | "monospace";
font_size: number;
line_height: number;
margin_width: number;
background_color: string;
text_color: string;
brightness: number;
orientation_lock: "auto" | "portrait" | "landscape";
theme: "sepia" | "dark" | "light" | "paper";
}
interface ReadingProgress {
current_chapter: number;
current_position: number;
percentage: number;
updated_at: string;
}
```
### CSS / Theming
Reading view uses CSS custom properties driven by reading settings:
```css
:root {
--reader-bg: var(--bg-color, #f5f0eb);
--reader-text: var(--text-color, #1a1a1a);
--reader-font-family: var(--font-family, "Georgia", serif);
--reader-font-size: var(--font-size, 18px);
--reader-line-height: var(--line-height, 1.6);
--reader-margin: var(--margin-width, 16px);
}
```
Three theme presets:
- **Sepia**: `bg:#f5f0eb`, `text:#1a1a1a` — warm, easy on eyes
- **Dark**: `bg:#1a1a2e`, `text:#e0e0e0` — for low-light reading
- **Light**: `bg:#ffffff`, `text:#1a1a1a` — crisp and clean
- **Paper**: `bg:#e8e0d4`, `text:#2c2c2c` — book-like feel
### Orientation Support
- CSS `@media (orientation: portrait)` and `@media (orientation: landscape)` breakpoints
- Reading settings panel includes orientation lock toggle
- On mobile, landscape mode expands content horizontally with wider margins
- Portrait mode stacks controls vertically for thumb-reachable UI
### Routing
Add to App.tsx:
```
/ → LibraryPage
/books/:bookId → BookDetailPage
/reader/:bookId → ReadingPage
```
---
## Implementation Order
1. Backend models + migrations (Chapter, ReadingProgress, ReadingSettings)
2. Backend serializers + views + URLs
3. Frontend types + API client
4. Frontend hooks (useReadingSettings, useChapters, useReadingProgress)
5. Frontend components (ReadingSettingsPanel, TableOfContents, ReaderToolbar)
6. Frontend page (ReadingPage)
7. Routing updates
8. CSS / theming
+73
View File
@@ -0,0 +1,73 @@
# 011 — Web reader (react-reader)
**Status:** Implemented
**Created:** 2026-06-03
## Objective
Replace the custom HTML chapter reader with [react-reader](https://github.com/gerhardsletten/react-reader) (epub.js) for paginated EPUB reading in the web app. Keep the existing toolbar and settings panel chrome. Block PDFs from the in-browser reader.
## Architecture
```
Library (EPUB only) → /read/:id → ReadingPage
→ GET /api/books/ebooks/{id}/ (metadata, format guard)
→ GET /api/books/ebooks/{id}/file/ (authenticated EPUB blob)
→ ReactReader (blob URL + CFI location)
→ PATCH /api/books/ebooks/{id}/progress/ (epub_location + percentage)
```
EPUB files are fetched with JWT via the API, converted to a blob URL client-side, and passed to react-reader. This avoids unauthenticated `/media/` URLs and CORS issues in dev.
## Backend changes
### `GET /api/books/ebooks/{id}/file/`
- Authenticated, owner-only
- Returns `FileResponse` with `Content-Type: application/epub+zip`
- Returns `400` if format is not `epub`
### `ReadingProgress.epub_location`
| Field | Type | Notes |
|-------|------|-------|
| `epub_location` | CharField(2048) | EPUB CFI string for resume position |
Exposed on `GET/PATCH /api/books/ebooks/{id}/progress/` and in `EBookDetail.progress`.
`current_position` stores whole-book percentage (0100) derived from epub.js locations.
## Frontend changes
| File | Change |
|------|--------|
| `pages/ReadingPage.tsx` | `ReactReader` replaces chapter HTML rendering |
| `hooks/useEpubReader.ts` | Blob load, CFI state, debounced progress save |
| `utils/epubRendition.ts` | Theme/font application via `getRendition` |
| `api/books.ts` | `getEpubFile(id)` |
| `pages/Library.tsx` | Block PDF open with notice |
| `App.tsx` | Single route `/read/:id`; `/reader/:id` redirects |
### Removed (web-only)
- `useChapters.ts`, `TableOfContents.tsx`, `Reader.tsx`, `useReadingProgress.ts`
Backend `/toc/` and `/content/` endpoints remain for mobile/API consumers.
## EPUB-only policy
- Library: clicking a PDF shows a dismissible notice; no navigation to reader
- ReadingPage: deep-link guard if `format !== 'epub'`
- Upload still accepts PDF for storage; web reader is EPUB-only
## Dependencies
- Frontend: `react-reader` (^2.0.15)
## Verification
1. Open an EPUB from library → paginated reading, swipe/tap page turns, built-in TOC
2. Close and reopen → resumes at saved CFI
3. Change theme/font in settings → applies inside epub iframe
4. Click a PDF in library → notice shown, reader not opened
5. `GET /api/books/ebooks/{id}/file/` without auth → 401
+56
View File
@@ -0,0 +1,56 @@
# 012 — Library book context menu
**Status:** Implemented
**Created:** 2026-06-03
## Objective
Add a custom right-click context menu on each book card in the library with **Sync metadata** (Open Library refresh) and **Remove** (delete from library). Show toast notifications for success, warning, and error outcomes.
## Architecture
```
Library book card (contextmenu)
→ BookContextMenu
→ Sync metadata: POST /api/books/ebooks/{id}/enrich-metadata/
→ Remove: confirm dialog → DELETE /api/books/ebooks/{id}/
→ ToastProvider (app-wide) → success | warning | error toasts
```
Backend endpoints already exist; no API changes required.
## Frontend changes
| File | Change |
|------|--------|
| `api/books.ts` | `enrichEBookMetadata(id)``POST .../enrich-metadata/` |
| `types/book.ts` | `OpenLibraryMetadata` with `match_status` |
| `hooks/useToast.tsx` | `ToastProvider`, `showToast({ message, variant })`, auto-dismiss ~4s |
| `components/ToastContainer.tsx` | Fixed bottom-right toast stack |
| `components/BookContextMenu.tsx` | Positioned menu; sync + remove actions |
| `pages/Library.tsx` | `onContextMenu` on cards; local state updates |
| `App.tsx` | Wrap routes with `ToastProvider` |
## Context menu behavior
- Opens on right-click (`contextmenu`); browser default menu suppressed
- Position clamped to viewport; closes on outside click, Escape, or scroll
- **Sync metadata:** updates card title/author/cover from response; toast by `metadata.match_status`:
- `matched` → success
- `not_found` → warning
- other → neutral success
- **Remove:** `window.confirm` before delete; removes card from list on success
- Menu clicks do not trigger card navigation to reader
## Dependencies
None (no new npm packages).
## Verification
1. Right-click a book → menu appears at cursor
2. Sync metadata on a matched book → cover/title may update, success toast
3. Sync on unmatched book → warning toast, no crash
4. Remove → confirm → book disappears, success toast
5. Cancel remove → book stays, menu closes
6. Left-click still opens reader (EPUB) or PDF notice
+54
View File
@@ -0,0 +1,54 @@
# 013 — Frontend i18n (react-i18n-lite)
**Status:** Implemented
**Created:** 2026-06-04
## Objective
Internationalize the web UI in English (`en-US`) and Spanish (`es-ES`) using [react-i18n-lite](https://www.npmjs.com/package/react-i18n-lite).
## Architecture
```
App.tsx
└── I18nProvider (TranslationContainer)
├── resolveDefaultLanguage() — localStorage → navigator → en-US
├── LanguagePersistence — sync setLanguage ↔ localStorage, html[lang]
└── AuthProvider → ToastProvider → routes
```
Components call `useTranslation()` and `t('namespace.key', { interpolation })`.
## Locale files
| File | Purpose |
|------|---------|
| `frontend/src/locales/en-US.ts` | English dictionary |
| `frontend/src/locales/es-ES.ts` | Spanish dictionary |
| `frontend/src/locales/index.ts` | `locales` map, `SupportedLanguage`, helpers |
Key namespaces: `common`, `auth`, `library`, `contextMenu`, `toast`, `settings`, `reader`, `addBook`, `bookDetail`, `search`, `annotations`.
## Language selection
- **Settings page** — UI Language dropdown (English / Español)
- **Persistence**`localStorage` key `cloud-reader.locale`
- **Default** — saved preference, else `navigator.language` (`es*``es-ES`), else `en-US`
## Scope
**Translated:** All user-facing chrome (library, auth, reader toolbar/settings/TOC, add book, book detail, annotations, context menu, toasts).
**Not translated:** EPUB body content, API error bodies, backend `reading_status_display`, Open Library metadata fields.
## Dependencies
- `react-i18n-lite` (^1.0.10)
## Verification
1. Open app — labels match browser or saved language
2. Settings → switch to Español — library/auth update without reload
3. Refresh — language persists
4. Context menu and toasts show translated strings
5. `yarn build` succeeds
@@ -0,0 +1,46 @@
# 014 — Library reading progress badges
**Status:** Implemented
**Created:** 2026-06-04
## Objective
Show accurate reading state on library book cards using saved EPUB progress instead of always displaying "Want to Read" (`Quiero leer`).
## Data source
- `GET /api/books/ebooks/` returns `progress` (0100) from `ReadingProgress.current_position` via `EBookListSerializer.get_progress`.
- Progress is updated by the web reader (`useEpubReader``PATCH .../progress/`).
## Display rules
| Progress | `opened` | Badge | Cover extra |
|----------|----------|-------|-------------|
| `null` or `0` | false | Want to Read | — |
| `0` | true | Opened | — |
| `198` | — | Reading · N% | Green progress bar on cover |
| `≥ 99` | — | Finished | — |
**Opened** is set when the reader first displays (CFI saved, 0% progress). Turning pages moves to **Reading**.
Threshold constant: `FINISHED_PROGRESS_THRESHOLD = 99` in `frontend/src/utils/libraryStatus.ts`.
## Frontend changes
| File | Change |
|------|--------|
| `utils/libraryStatus.ts` | `deriveReadingStatus`, `normalizeProgressPercent` |
| `pages/Library.tsx` | Map API progress → status; badge labels; client-side status filter |
| `locales/en-US.ts`, `es-ES.ts` | `library.readingStatus.readingWithProgress` |
| `pages/Library.module.css` | Cover progress bar styles |
## Status filter
The reading-status filter in the library panel now filters client-side by derived status (`want_to_read`, `reading`, `finished`).
## Verification
1. New upload → **Want to Read** / **Quiero leer**
2. Partial read → **Reading · N%** + progress bar
3. Near end (≥ 99%) → **Finished** / **Terminado**
4. Filters by Leyendo / Terminado work as expected
+83
View File
@@ -0,0 +1,83 @@
# 015 — EPUB bookmarks and notes (physical-book UX)
**Status:** Implemented
**Created:** 2026-06-04
## Objective
Let users select passage text while reading an EPUB, save a bookmark (with optional thought), list markers in chapter order, and jump back to the exact location. Global view groups markers by book in a Reddit-style thread layout.
## Fix: AnnotationsProvider
`AnnotationsProvider` wraps all routes in [`App.tsx`](frontend/src/App.tsx) so `/bookmarks-notes` and the reader can use `useAnnotations()`.
## Data model
`Bookmark` (annotations app) references **`books.EBook`**, not catalog `Book`:
| Field | Purpose |
|-------|---------|
| `ebook` | FK to uploaded ebook |
| `epub_cfi` | EPUB CFI anchor |
| `chapter_index` | Spine index for sort order |
| `chapter_title` | Display label |
| `location_text` | Selected passage quote |
| `content` | Optional user thought (empty = bookmark only) |
| `page` | Legacy display field (`chapter_index + 1`) |
Unique: `(user, ebook, epub_cfi)`.
Default API ordering: `chapter_index`, `epub_cfi`.
Legacy `Note` model remains for old API; new UX uses `Bookmark.content` only.
## Reader flow
```mermaid
sequenceDiagram
participant User
participant EpubView
participant Popover
participant API
User->>EpubView: Select text
EpubView->>Popover: Show near selection
User->>Popover: Save optional thought
Popover->>API: POST /annotations/bookmarks/
```
1. Text selection via epub.js `selected` event and content `mouseup` hook.
2. [`SelectionPopover`](frontend/src/components/reader/SelectionPopover.tsx) — floating UI, optional textarea.
3. Toolbar bookmark icon opens [`BookMarkersPanel`](frontend/src/components/reader/BookMarkersPanel.tsx) (current ebook only).
4. “Go to passage” sets reader `location` to stored CFI (`/read/:id` with router state).
## Global page (`/bookmarks-notes`)
[`MarkerThreadsView`](frontend/src/components/annotations/MarkerThreadsView.tsx):
- Groups markers by ebook (collapsible book rows).
- Within each book: chapter order, passage as blockquote, thought indented below (Reddit-style).
- Optional filter: `/bookmarks-notes/:ebookId`.
## API
- `GET /api/annotations/bookmarks/?ebook={id}`
- `POST /api/annotations/bookmarks/` — body: `ebook`, `epub_cfi`, `chapter_index`, `chapter_title`, `location_text`, `content`
## i18n
New keys under `annotations.*` (EN/ES): `saveMarker`, `thoughtPlaceholder`, `bookmarkOnly`, `selectTextHint`, `goToPassage`, `inBookPanel`, `markerCount`, etc.
## Verification
1. `/bookmarks-notes` loads without provider error.
2. Select text in reader → popover → save with/without thought.
3. Markers appear in reader panel and global page under correct book, in chapter order.
4. “Go to passage” opens the correct location.
See also [016 — Bookmark reading anchor](016-bookmark-reading-anchor.md) for preserving reading position while peeking at bookmarks.
## Out of scope
- PDF selection
- Multiple replies per passage
- Migrating legacy `Note` rows into `Bookmark`
@@ -0,0 +1,87 @@
# 016 — Bookmark reading anchor
**Status:** Implemented
**Created:** 2026-06-04
**See also:** [015 — EPUB bookmarks and notes](015-epub-bookmarks-notes.md)
## Objective
Let users jump to a bookmark to review a passage without overwriting their true reading position. While peeking, show a high-visibility control to return to where they were reading.
## Definitions
| Term | Meaning |
|------|---------|
| **Reading anchor** | EPUB CFI (+ optional %) captured immediately before a bookmark peek |
| **Peek mode** | Temporary view at a bookmark location; server `ReadingProgress` is not updated |
| **Resume** | Jump back to the reading anchor and re-enable progress persistence |
## Problem (before)
“Go to passage” called `jumpToCfi`, which triggered `locationChanged` and debounced `PATCH /books/ebooks/{id}/progress/`, replacing `epub_location` and `current_position` with the bookmark. Library progress and the next reading session started at the bookmark instead of the real position.
## Triggers (enter peek mode)
- In-reader: **Go to passage** in [`BookMarkersPanel`](../frontend/src/components/reader/BookMarkersPanel.tsx)
- Global: **Go to passage** on [`/bookmarks-notes`](../frontend/src/components/annotations/BookmarksNotesPage.tsx) → `/read/:id` with `state.epubLocation` (bookmark CFI)
## Non-triggers
- Table of contents navigation
- Prev / next page buttons
- Creating a new bookmark from text selection
- Opening the book normally from the library (no `epubLocation` in router state)
## UX
### Resume control
- Component: [`ResumeReadingButton`](../frontend/src/components/reader/ResumeReadingButton.tsx)
- Visible only when `isBookmarkPeekActive && readingAnchor != null`
- Position: right edge, **above** the next-page chevron (`.reader-page-nav--next`)
- Style: warm accent (`#ea580c` / `#f97316`), white label/icon; distinct from muted gray nav buttons
- Action: `resumeReadingAnchor()` — hides control, returns to anchor CFI
- i18n: `reader.resumeReading`, `reader.resumeReadingAria`
### Anchor policy
- First anchor is captured when peek starts; **additional “Go to passage” clicks while peeking do not replace the anchor** until the user resumes or leaves the reader.
## Progress rules
| Mode | `PATCH .../progress/` |
|------|------------------------|
| Normal reading | Yes (debounced on `locationChanged`, sync after locations ready) |
| Bookmark peek | **No**`flushProgress` / `scheduleProgress` no-op |
| After resume | Yes — flush anchor CFI and percentage once |
### Load from bookmarks page
1. Fetch saved `ReadingProgress` from API.
2. If `state.epubLocation` is set and saved `epub_location` exists → store saved location as **anchor**, set peek mode, open at bookmark CFI.
3. Do not persist bookmark location as progress during peek.
### In-reader peek
1. Capture current `location` (CFI) as anchor (if valid).
2. Enter peek mode, jump to bookmark CFI.
## API
No backend changes in v1. Anchor is session-only in [`useEpubReader`](../frontend/src/hooks/useEpubReader.ts).
## Verification
1. Read to ~30%, open markers, **Go to passage** on an early bookmark → jumps; after debounce, library/API progress still reflects ~30% (not bookmark).
2. Orange **Back to reading** appears above the next chevron only during peek.
3. Tap resume → returns to ~30%; button hides; progress saves resume.
4. From `/bookmarks-notes`, **Go to passage** → peek + resume using saved progress as anchor.
5. TOC / prev / next do not show the resume button.
6. EN/ES strings present.
## Out of scope
- PDF reader
- Multiple anchor history stack
- Backend `resume_epub_location` field
- Peek mode for TOC jumps
+99
View File
@@ -0,0 +1,99 @@
# Mobile Book Search & Discovery — Spec
## Overview
Enhance the existing book search experience with mobile-first features: voice search via the Web Speech API, real-time autocomplete suggestions, and touch-optimized responsive layout.
## Prerequisites
- Backend endpoints already exist (from `docs/backend/search-discovery-spec.md`):
- `GET /api/books/?q=...&genre=...&author=...&reading_status=...` — paginated search
- `GET /api/books/{id}/` — book detail
- `GET /api/books/genres/` — genre discovery
- `GET /api/books/authors/` — author discovery
- Frontend `LibraryPage` and `BookDetailPage` components exist but lacked API client methods and types (fixed in this PR).
## Frontend API Client Additions
### `frontend/src/types/book.ts` — New exports
```typescript
export interface BookSearchParams {
q?: string;
genre?: string;
author?: string;
reading_status?: string;
ordering?: string;
page?: number;
page_size?: number;
}
export const READING_STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "", label: "All Statuses" },
{ value: "want_to_read", label: "Want to Read" },
{ value: "reading", label: "Reading" },
{ value: "finished", label: "Finished" },
{ value: "dnf", label: "Did Not Finish" },
];
```
### `frontend/src/api/books.ts` — New methods on `booksApi`
| Method | Endpoint | Returns |
|--------|----------|---------|
| `searchBooks(params)` | `GET /api/books/` | `{ count, results: BookListItem[] }` |
| `getBook(id)` | `GET /api/books/{id}/` | `BookDetail` |
| `getGenres()` | `GET /api/books/genres/` | `string[]` |
| `getAuthors()` | `GET /api/books/authors/` | `string[]` |
## Mobile Features
### 1. Voice Search
- **Hook**: `useVoiceSearch` in `frontend/src/hooks/useVoiceSearch.ts`
- Uses the Web Speech API (`SpeechRecognition` / `webkitSpeechRecognition`)
- Returns: `{ isListening, transcript, isSupported, startListening, stopListening, hasError }`
- Renders a microphone icon button next to the search input
- On mobile, tapping the mic icon triggers the native speech recognition prompt
- On success, populates the search input with the transcript and triggers a search
- Graceful degradation: if SpeechRecognition API is unavailable, the mic button is hidden
### 2. Real-Time Suggestions (Autocomplete)
- Component: `SearchSuggestions` rendered as a dropdown below the search input
- On each keystroke (debounced 200ms), fetches `GET /api/books/?q=...&page_size=5` for suggestions
- Shows up to 5 book title/author suggestions in a styled dropdown list
- Clicking a suggestion navigates directly to `/books/{id}`
- Clicking outside or pressing Escape dismisses the dropdown
- Combines with existing full search results — suggestions are fast previews, not the main result list
### 3. Mobile-Responsive Enhancements
- Filters panel is **collapsed by default** on mobile, toggleable via a "Filters" button
- Touch targets minimum 44px (WCAG 2.1)
- Results grid switches to **single column** below 600px viewport width
- Search input and filters panel stack vertically on small screens
- Add CSS breakpoints via inline styles and a `useMediaQuery` hook
- Bottom navigation-style action buttons on mobile (Add Book, Bookmarks, Settings become icon-only)
## Component Hierarchy
```
LibraryPage
├── Header (title, count, action buttons)
├── SearchInput
│ ├── TextInput (debounced 300ms)
│ ├── VoiceSearchButton (microphone icon)
│ └── SearchSuggestions (dropdown, debounced 200ms)
├── FiltersButton (mobile: toggle; desktop: always visible)
├── FiltersPanel (collapsible on mobile)
│ ├── GenreSelect
│ ├── AuthorSelect
│ ├── StatusSelect
│ └── ClearFiltersButton
├── LoadingState (skeleton grid)
├── ErrorState (message + retry button)
├── EmptyState (no results / no books)
└── ResultsGrid (responsive: auto-fill vs single column)
```
## Mobile-First CSS Strategy
- Use inline styles with `@media` queries in a shared `breakpoints.ts` utility
- Breakpoints: sm = 480px, md = 768px, lg = 1024px
- Base styles are mobile-first (single column, full width)
- Media queries expand to multi-column grid and horizontal layout on larger screens
+96
View File
@@ -0,0 +1,96 @@
# 009 — Expo Mobile Application Integration
**Issue:** #16
**Status:** Draft
**Created:** 2026-05-29
## Objective
Integrate an Expo-based React Native mobile application into the `cloud-reader` monorepo, sharing types, API client patterns, and configuration with the existing web frontend.
## Directory Structure
```
cloud-reader/
├── mobile/ # Expo React Native app
│ ├── package.json
│ ├── app.json
│ ├── tsconfig.json
│ ├── babel.config.js
│ ├── App.tsx # Root component
│ ├── src/
│ │ ├── api/ # API client (mirrors frontend/src/api/ pattern)
│ │ │ ├── client.ts # Axios instance + JWT interceptor
│ │ │ ├── books.ts # Book API calls
│ │ │ └── annotations.ts
│ │ ├── screens/ # Screen-level components
│ │ ├── components/ # Reusable UI components
│ │ ├── navigation/ # React Navigation setup
│ │ ├── context/ # Auth context, etc.
│ │ ├── hooks/ # Custom hooks
│ │ └── types/ # Mobile-specific types
│ └── assets/
├── packages/
│ └── shared/
│ ├── package.json
│ ├── tsconfig.json
│ └── src/
│ ├── types.ts # Shared domain types (Book, User, Bookmark, Note)
│ └── utils.ts # Shared utility functions
└── package.json # Root — updated workspace config
```
## Monorepo Workspace Config
Root `package.json` workspaces array updated to include `"mobile"`, `"packages/shared"` alongside existing `"frontend"` and `"backend"`.
## Shared `packages/shared`
- `@cloud-reader/shared` package published within the monorepo
- Exports:
- All domain types (`Book`, `BookSummary`, `Bookmark`, `Note`, `User`, `AnnotationEntry`, `PaginatedResponse`, `TokenResponse`)
- API endpoint constants
- Date formatting helpers
- Validation utilities (email regex, password strength check)
## Mobile App Structure
### API Client (`mobile/src/api/client.ts`)
- Axios instance configured with:
- Base URL from environment variable (`EXPO_PUBLIC_API_URL`)
- JWT token attachment via request interceptor
- Token refresh response interceptor on 401
- Uses `AsyncStorage` for token persistence (instead of `localStorage`)
### Navigation (`mobile/src/navigation/`)
- React Navigation stack:
1. `AuthStack` — Login, Register screens
2. `MainTabs` — Library, Search, Settings tabs
3. `BookReader` — Full-screen reading view
### Key Screens
| Screen | Route | Purpose |
|--------|-------|---------|
| Login | `Auth/Login` | Email/password login |
| Register | `Auth/Register` | User registration |
| Library | `Main/Library` | Book list with filtering |
| BookDetail | `Main/BookDetail` | Book metadata + actions |
| Reader | `Reader/View` | EPUB/PDF rendering |
| Search | `Main/Search` | Book discovery |
| Settings | `Main/Settings` | Profile, theme, download mgmt |
## Backend Changes Required
None. The existing Django REST API already serves all endpoints needed by the mobile app. The mobile app communicates with the same backend via the shared API base URL.
## Docker
No changes to `docker-compose.yml` needed — the mobile app runs on-device or via Expo Go, not inside Docker.
## CI/CD Considerations
The monorepo structure supports a single pipeline that can:
- `yarn install` at root (installs all workspaces)
- `yarn workspace @cloud-reader/shared build`
- `yarn workspace @cloud-reader/mobile build` (Expo EAS for mobile builds)
- `yarn workspace @cloud-reader/frontend build` (Vite for web builds)
+150
View File
@@ -0,0 +1,150 @@
# 010 — Mobile EPUB Reader
**Status:** Implemented
**Created:** 2026-06-04
## Objective
Mirror the web reading experience (`frontend/src/pages/ReadingPage.tsx` and
`frontend/src/components/reader/*`) inside the Expo app so users can open their
uploaded library, read EPUBs with persisted progress and typography settings,
and manage bookmarks/highlights — reusing the same Django REST API as the web
client.
The web EPUB renderer (`react-reader` / epub.js) is DOM-only, so mobile renders
EPUBs with `@epubjs-react-native/core`, which runs epub.js inside a
`react-native-webview`. This keeps behavior (CFI locations, themes, font
controls, TOC, annotations) close to web while staying inside the managed Expo
workflow.
## Scope
### Mirrored from web
- EPUB rendering with swipe pagination (`flow: "paginated"`).
- Resume position and debounced progress save (CFI + percentage).
- Table of contents drawer with jump-to-chapter.
- Reading settings: theme presets (light/sepia/paper/dark), font family, font
size, line spacing — persisted to `/api/reader/settings/`.
- Bookmarks: bookmark the current page, list/jump/delete, and create highlights
from a text selection. Highlights are re-applied on open (best-effort).
### Deferred (not in this pass)
- PDF reading. PDF books show a placeholder pointing to the web reader.
- Brightness and orientation-lock controls.
- Per-highlight color picker (highlights use a single default color).
- App-wide internationalization (web uses `react-i18n-lite`).
## Architecture
```
LibraryScreen (EBook list)
| navigate("BookDetail", { ebookId })
v
BookDetailScreen ----------------> ReaderScreen ({ ebookId })
|
GET /api/books/ebooks/:id/ (format guard)
|
epub? --------------------- pdf? -> placeholder
|
EpubReaderView (ReaderProvider)
|
expo-file-system downloadAsync(file/, Bearer token) -> file:// uri
|
<Reader src=file:// fileSystem=useFileSystem flow="paginated" />
|
onLocationChange -> debounce 800ms -> PATCH progress/
onSelected -> POST bookmarks/ (+ highlight annotation)
useReader().toc -> goToLocation(href)
settings change -> changeTheme / changeFontSize / changeFontFamily
+ PATCH /api/reader/settings/ (debounced)
```
The ebook file is downloaded to the app cache with an `Authorization` header
(the `/file/` endpoint is JWT-protected) and the local `file://` URI is handed
to the renderer — mirroring how the web client downloads a blob rather than
using a public/signed URL.
## Mobile changes
| File | Role |
|------|------|
| `mobile/src/api/client.ts` | Adds `apiClient`, `saveTokens`, `loadTokens`, `getApiBaseUrl` |
| `mobile/src/api/ebooks.ts` | `/api/books/ebooks/` list/detail/toc + `getFileUrl(id)` |
| `mobile/src/api/reader.ts` | Reader settings + per-book progress (mirrors web `api/reader.ts`) |
| `mobile/src/api/annotations.ts` | Bookmarks CRUD against `/api/annotations/bookmarks/` |
| `mobile/src/types/reader.ts` | `ReadingSettings`, `ReadingProgress` (full reader shapes) |
| `mobile/src/types/index.ts` | `AppStackParamList`, `Bookmark`, `CreateMarkerPayload` |
| `mobile/src/hooks/useReadingSettings.ts` | Loads + debounced-saves reader settings |
| `mobile/src/utils/epubTheme.ts` | Font stacks, theme palettes, `buildEpubTheme()` |
| `mobile/src/navigation/AppStack.tsx` | Native stack: Tabs / BookDetail / Reader |
| `mobile/src/screens/LibraryScreen.tsx` | Lists user EBooks (`ebooksApi.list`) |
| `mobile/src/screens/BookDetailScreen.tsx` | Metadata + start/resume button |
| `mobile/src/screens/ReaderScreen.tsx` | Format guard: EPUB view vs PDF placeholder |
| `mobile/src/components/reader/EpubReaderView.tsx` | Reader, progress, bookmarks, settings wiring |
| `mobile/src/components/reader/ReaderToolbar.tsx` | Title, chapter, progress bar, action buttons |
| `mobile/src/components/reader/TocModal.tsx` | Table of contents sheet |
| `mobile/src/components/reader/ReadingSettingsModal.tsx` | Theme/font/size/spacing controls |
| `mobile/src/components/reader/BookmarksModal.tsx` | Bookmarks & highlights list |
| `mobile/App.tsx` | Wraps the tree in `GestureHandlerRootView` |
Removed unused scaffolding: `mobile/src/navigation/AppNavigator.tsx`,
`mobile/src/navigation/MainTabs.tsx`.
## API contracts (consumed)
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/api/books/ebooks/` | User library list |
| GET | `/api/books/ebooks/:id/` | EBook detail (format, progress, cover) |
| GET | `/api/books/ebooks/:id/file/` | Stream EPUB bytes (JWT, owner) |
| GET/PATCH | `/api/books/ebooks/:id/progress/` | Reading progress (`current_position`, `last_page`, `epub_location`) |
| GET/PATCH | `/api/reader/settings/` | Reader typography/theme settings |
| GET/POST/DELETE | `/api/annotations/bookmarks/` | Bookmarks & highlights (`ebook`, `epub_cfi`, `chapter_index`, ...) |
## Settings mapping (web -> mobile)
| Reader setting | Web (epub.js) | Mobile (`@epubjs-react-native/core`) |
|----------------|---------------|--------------------------------------|
| `theme` / colors | `themes.register/select` | `changeTheme(buildEpubTheme())` + `defaultTheme` |
| `font_size` | `themes.fontSize` | `changeFontSize("Npx")` |
| `font_family` | body font-family | `changeFontFamily(stack)` |
| `line_height` | body line-height | `buildEpubTheme()` CSS rule |
| `margin_width` | gap-based padding | not applied (deferred) |
| `brightness` / `orientation_lock` | applied on web | deferred |
## Dependencies added
- `@epubjs-react-native/core@1.4.7`
- `@epubjs-react-native/expo-file-system@1.1.4`
- `react-native-webview@13.12.5`
(`react-native-gesture-handler`, `react-native-reanimated`, and
`expo-file-system` were already present.)
## Configuration
| Env var | Purpose |
|---------|---------|
| `EXPO_PUBLIC_API_URL` | Backend base URL (e.g. `http://10.0.2.2:8000` on Android emulator, LAN IP on a device) |
## Compatibility notes
- The Expo file-system adapter (`@epubjs-react-native/expo-file-system`) depends
only on `expo-file-system`, so the reader runs in Expo Go. (The library's
bare adapter pulls native `@dr.pogodin/react-native-fs`; that path is not
used here.)
- React 19 / Expo SDK 52 may surface peer-dependency warnings for the
`@epubjs-react-native/*` packages.
## Verification
- [ ] Log in; Library lists the user's uploaded EBooks with covers/progress.
- [ ] Open an EPUB; it renders and paginates by swipe.
- [ ] Reopen a book; it resumes at the last position.
- [ ] Change theme/font/size/spacing; the page updates and persists across reopen.
- [ ] Open the TOC and jump to a chapter.
- [ ] Bookmark the current page; it appears in the bookmarks list and can be re-opened/deleted.
- [ ] Select text to create a highlight; it persists and re-renders on reopen.
- [ ] Open a PDF book; the placeholder is shown instead of a crash.
+12
View File
@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . ./
EXPOSE 5173
CMD ["yarn", "dev", "--host"]
+1
View File
@@ -0,0 +1 @@
import{X as j,h as S,r as d,j as e}from"./index-BQiEVoRj.js";import{b as B}from"./books-BG7wMiRx.js";import{g as v}from"./errors-bLl0IgZb.js";function R(){const{t:o}=j(),s=S(),[a,p]=d.useState(""),[c,b]=d.useState(""),[n,u]=d.useState(null),[r,f]=d.useState(!1),[x,i]=d.useState(null),y=t=>{var g,h;const l=((g=t.target.files)==null?void 0:g[0])??null;if(l){const m=(h=l.name.split(".").pop())==null?void 0:h.toLowerCase();if(m!=="epub"&&m!=="pdf"){i(o("addBook.fileTypeError")),u(null);return}u(l),i(null),a||p(l.name.replace(/\.(epub|pdf)$/i,"").slice(0,512))}},k=async t=>{if(t.preventDefault(),!n||!a.trim()){i(o("addBook.requiredError"));return}f(!0),i(null);try{await B.uploadEBook(n,a.trim(),c.trim()),s("/")}catch(l){i(v(l,o("addBook.uploadFailed")))}finally{f(!1)}};return e.jsxs("div",{style:{maxWidth:500,margin:"0 auto",padding:16,minHeight:"100vh",background:"#f8f9fa"},children:[e.jsxs("header",{style:{display:"flex",alignItems:"center",gap:12,marginBottom:24,padding:"16px 0",borderBottom:"1px solid #eee"},children:[e.jsxs("button",{onClick:()=>s("/"),style:{padding:"8px 16px",borderRadius:6,border:"1px solid #ddd",background:"#fff",cursor:"pointer",fontSize:14},children:["← ",o("common.back")]}),e.jsx("h1",{style:{fontSize:24,fontWeight:700,color:"#1a1a2e",margin:0},children:o("addBook.title")})]}),e.jsxs("form",{onSubmit:k,style:{display:"flex",flexDirection:"column",gap:20},children:[x&&e.jsx("div",{style:{background:"#fde8e8",padding:12,borderRadius:6,color:"#e74c3c",fontSize:14},children:x}),e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[e.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:o("addBook.fileLabel")}),e.jsx("input",{type:"file",accept:".epub,.pdf",onChange:y,style:{padding:"10px 0"}}),n&&e.jsx("p",{style:{fontSize:13,color:"#666",marginTop:4},children:o("addBook.selected",{name:n.name})})]}),e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[e.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:o("addBook.titleLabel")}),e.jsx("input",{type:"text",value:a,onChange:t=>p(t.target.value),placeholder:o("addBook.titlePlaceholder"),style:{padding:"10px 12px",borderRadius:6,border:"1px solid #ddd",fontSize:16,outline:"none"}})]}),e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[e.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:o("addBook.authorLabel")}),e.jsx("input",{type:"text",value:c,onChange:t=>b(t.target.value),placeholder:o("addBook.authorPlaceholder"),style:{padding:"10px 12px",borderRadius:6,border:"1px solid #ddd",fontSize:16,outline:"none"}})]}),e.jsx("button",{type:"submit",disabled:r,style:{padding:"12px 24px",borderRadius:8,border:"none",background:"#1a1a2e",color:"#fff",fontSize:16,fontWeight:600,cursor:"pointer",opacity:r?.6:1,marginTop:8},children:o(r?"addBook.uploading":"addBook.upload")})]})]})}export{R as AddBookPage};
+1
View File
@@ -0,0 +1 @@
import{j as t,X as v,r as o,e as w,h as z}from"./index-BQiEVoRj.js";import{g as A}from"./errors-bLl0IgZb.js";function h({isLogin:n,onToggle:p}){const{t:e}=v(),[i,g]=o.useState(""),[a,f]=o.useState(""),[d,x]=o.useState(""),[c,s]=o.useState(null),[l,u]=o.useState(!1),{login:m,register:y}=w(),b=z(),j=async r=>{if(r.preventDefault(),s(null),!n&&a!==d){s(e("auth.passwordsMismatch"));return}u(!0);try{n?await m(i,a):await y(i,a),b("/")}catch(S){s(A(S,e(n?"auth.loginFailed":"auth.registrationFailed")))}finally{u(!1)}};return t.jsx("div",{style:{display:"flex",justifyContent:"center",alignItems:"center",minHeight:"100vh",background:"#f8f9fa",padding:16},children:t.jsxs("div",{style:{width:"100%",maxWidth:400,background:"#fff",borderRadius:12,padding:32,boxShadow:"0 2px 16px rgba(0,0,0,0.08)"},children:[t.jsx("h1",{style:{fontSize:28,fontWeight:700,color:"#1a1a2e",textAlign:"center",marginBottom:4},children:e("common.appName")}),t.jsx("h2",{style:{fontSize:16,color:"#888",textAlign:"center",marginBottom:24,fontWeight:400},children:e(n?"auth.signIn":"auth.createAccount")}),t.jsxs("form",{onSubmit:j,style:{display:"flex",flexDirection:"column",gap:16},children:[c&&t.jsx("div",{style:{background:"#fde8e8",padding:12,borderRadius:6,color:"#e74c3c",fontSize:14},children:c}),t.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[t.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:e("auth.email")}),t.jsx("input",{type:"email",value:i,onChange:r=>g(r.target.value),placeholder:e("auth.emailPlaceholder"),required:!0,style:{padding:"10px 12px",borderRadius:6,border:"1px solid #ddd",fontSize:16,outline:"none"}})]}),t.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[t.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:e("auth.password")}),t.jsx("input",{type:"password",value:a,onChange:r=>f(r.target.value),placeholder:e("auth.passwordPlaceholder"),required:!0,minLength:8,style:{padding:"10px 12px",borderRadius:6,border:"1px solid #ddd",fontSize:16,outline:"none"}})]}),!n&&t.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[t.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:e("auth.confirmPassword")}),t.jsx("input",{type:"password",value:d,onChange:r=>x(r.target.value),placeholder:e("auth.confirmPasswordPlaceholder"),required:!0,minLength:8,style:{padding:"10px 12px",borderRadius:6,border:"1px solid #ddd",fontSize:16,outline:"none"}})]}),t.jsx("button",{type:"submit",disabled:l,style:{padding:"12px 24px",borderRadius:8,border:"none",background:"#1a1a2e",color:"#fff",fontSize:16,fontWeight:600,cursor:"pointer",opacity:l?.6:1,marginTop:8},children:e(l?n?"auth.signingIn":"auth.creatingAccount":n?"auth.signIn":"auth.createAccount")})]}),t.jsxs("p",{style:{textAlign:"center",marginTop:20,color:"#888",fontSize:14},children:[n?e("auth.noAccount")+" ":e("auth.hasAccount")+" ",t.jsx("button",{onClick:p,style:{background:"none",border:"none",color:"#1a1a2e",fontWeight:600,cursor:"pointer",fontSize:14,textDecoration:"underline"},children:e(n?"auth.register":"auth.signIn")})]})]})})}function W({onToggle:n}){return t.jsx(h,{isLogin:!0,onToggle:n})}function k({onToggle:n}){return t.jsx(h,{isLogin:!1,onToggle:n})}export{W as LoginPage,k as RegisterPage};
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{X as m,u,r as l,j as e,h as g,i as j}from"./index-BQiEVoRj.js";function N({ebookIdFilter:n,onGoToPassage:i}){const{t:s}=m(),{markersByBook:r,loadBookmarks:c,removeBookmark:k,state:h}=u(),[p,x]=l.useState({});l.useEffect(()=>{c(n)},[c,n]);const d=l.useMemo(()=>{if(!n)return r;const a=Number(n);return r.filter(o=>o.ebookId===a)},[r,n]);if(h.bookmarksLoading)return e.jsx("div",{className:"annotations-loading",children:s("annotations.loading")});if(d.length===0)return e.jsxs("div",{className:"annotations-empty",children:[e.jsx("p",{children:s("annotations.empty")}),e.jsx("p",{style:{fontSize:14,color:"#6b7280",marginTop:8},children:s("annotations.selectTextHint")})]});const b=a=>{x(o=>({...o,[a]:!o[a]}))};return e.jsx("div",{className:"marker-books-list",children:d.map(a=>{const o=p[a.ebookId]??!1;return e.jsxs("section",{className:"marker-book-group",children:[e.jsxs("button",{type:"button",className:"marker-book-header",onClick:()=>b(a.ebookId),children:[e.jsx("span",{className:"marker-book-title",children:a.ebookTitle}),e.jsx("span",{className:"marker-book-count",children:s("annotations.markerCount",{count:String(a.markers.length)})}),e.jsx("span",{className:"marker-book-chevron",children:o?"▸":"▾"})]}),!o&&e.jsx("ul",{className:"marker-thread-list",children:a.markers.map(t=>e.jsxs("li",{className:"marker-thread",children:[e.jsx("div",{className:"marker-thread-meta",children:t.chapter_title?e.jsx("span",{className:"marker-thread-chapter",children:t.chapter_title}):e.jsx("span",{className:"marker-thread-chapter",children:s("annotations.chapterIndex",{index:String(t.chapter_index+1)})})}),t.location_text&&e.jsxs("blockquote",{className:"annotation-quote marker-thread-passage",children:["“",t.location_text,"”"]}),t.content?e.jsx("p",{className:"marker-thread-thought marker-thread-reply",children:t.content}):e.jsx("span",{className:"annotation-kind-badge bookmark-badge",children:s("annotations.bookmarkOnly")}),e.jsxs("div",{className:"annotation-actions",children:[e.jsx("button",{type:"button",className:"btn btn-sm",onClick:()=>i(t.ebook_id,t.epub_cfi),children:s("annotations.goToPassage")}),e.jsx("button",{type:"button",className:"btn btn-sm btn-danger",onClick:()=>k(t.id),children:s("common.delete")})]})]},t.id))})]},a.ebookId)})})}function y(){const{t:n}=m(),i=g(),{bookId:s}=j();return e.jsxs("div",{className:"page bookmarks-notes-page",style:{maxWidth:720,margin:"0 auto",padding:16},children:[e.jsxs("header",{style:{display:"flex",alignItems:"center",gap:12,marginBottom:24},children:[e.jsxs("button",{type:"button",onClick:()=>i("/"),style:{padding:"8px 16px",borderRadius:6,border:"1px solid #ddd",background:"#fff",cursor:"pointer",fontSize:14},children:["← ",n("common.back")]}),e.jsx("h2",{style:{margin:0,fontSize:22,fontWeight:700},children:n("annotations.title")})]}),e.jsx(N,{ebookIdFilter:s,onGoToPassage:(r,c)=>{i(`/read/${r}`,{state:{epubLocation:c}})}})]})}export{y as BookmarksNotesPage};
+1
View File
@@ -0,0 +1 @@
._container_1fxkh_1{position:absolute;top:100%;left:0;right:0;z-index:100;background:#fff;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 10px 10px;box-shadow:0 8px 24px #0000001f;max-height:320px;overflow-y:auto}._infoText_1fxkh_31{padding:12px 16px;color:#9ca3af;font-size:13px}._suggestionItem_1fxkh_43{display:flex;align-items:center;gap:12px;padding:10px 16px;cursor:pointer;border-bottom:1px solid #f3f4f6;min-height:44px}._suggestionItem_1fxkh_43:hover{background:#f9fafb}._coverImage_1fxkh_71{width:32px;height:48px;object-fit:cover;border-radius:4px}._coverPlaceholder_1fxkh_85{font-size:20px;flex-shrink:0}._bookInfo_1fxkh_95{min-width:0}._bookTitle_1fxkh_103{font-size:14px;font-weight:600;color:#1f2937;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}._bookAuthor_1fxkh_121{font-size:12px;color:#6b7280;margin-top:2px}._menu_ekpcj_1{position:fixed;z-index:9999;min-width:180px;background:#fff;border:1px solid #e5e7eb;border-radius:8px;box-shadow:0 4px 20px #0000001f;padding:4px 0;overflow:hidden}._item_ekpcj_25{display:block;width:100%;padding:10px 16px;border:none;background:transparent;text-align:left;font-size:14px;color:#374151;cursor:pointer;transition:background .1s}._item_ekpcj_25:hover:not(:disabled){background:#f3f4f6}._item_ekpcj_25:disabled{opacity:.5;cursor:not-allowed}._itemDanger_ekpcj_69{color:#dc2626}._itemDanger_ekpcj_69:hover:not(:disabled){background:#fef2f2}._itemLoading_ekpcj_85{color:#6b7280}._separator_ekpcj_93{height:1px;background:#e5e7eb;margin:4px 0}._bookCard_11p5a_1{background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px #0000000f;cursor:pointer;transition:transform .15s,box-shadow .15s}._bookCard_11p5a_1:hover{transform:translateY(-2px);box-shadow:0 4px 16px #0000001a}._coverProgressBar_11p5a_29{position:absolute;left:0;right:0;bottom:0;height:4px;background:#0000001f}._coverProgressFill_11p5a_47{height:100%;background:#16a34a;transition:width .2s ease}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{X as j,h as m,r as i,j as e}from"./index-BQiEVoRj.js";import{b as g}from"./books-BG7wMiRx.js";const S=[{value:"#ffffff",labelKey:"settings.bgWhite"},{value:"#f4e4c1",labelKey:"settings.bgSepia"},{value:"#1a1a2e",labelKey:"settings.bgDark"},{value:"#c7edcc",labelKey:"settings.bgGreen"}];function W(){const{t,language:u,setLanguage:p}=j(),x=m(),[s,a]=i.useState(null),[h,y]=i.useState(!0),[o,c]=i.useState(!1),[f,r]=i.useState(null),[v,d]=i.useState(!1);i.useEffect(()=>{(async()=>{try{const l=await g.getSettings();a(l)}catch(l){r(l instanceof Error?l.message:t("settings.loadFailed"))}finally{y(!1)}})()},[t]);const b=async()=>{if(s){c(!0),r(null),d(!1);try{await g.updateSettings(s),d(!0),setTimeout(()=>d(!1),2e3)}catch(n){r(n instanceof Error?n.message:t("settings.saveFailed"))}finally{c(!1)}}};return h?e.jsx("div",{style:{maxWidth:500,margin:"0 auto",padding:16,minHeight:"100vh",background:"#f8f9fa"},children:e.jsx("p",{children:t("settings.loading")})}):e.jsxs("div",{style:{maxWidth:500,margin:"0 auto",padding:16,minHeight:"100vh",background:"#f8f9fa"},children:[e.jsxs("header",{style:{display:"flex",alignItems:"center",gap:12,marginBottom:24,padding:"16px 0",borderBottom:"1px solid #eee"},children:[e.jsxs("button",{onClick:()=>x("/"),style:{padding:"8px 16px",borderRadius:6,border:"1px solid #ddd",background:"#fff",cursor:"pointer",fontSize:14},children:["← ",t("common.back")]}),e.jsx("h1",{style:{fontSize:24,fontWeight:700,color:"#1a1a2e",margin:0},children:t("settings.title")})]}),e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:24},children:[f&&e.jsx("div",{style:{background:"#fde8e8",padding:12,borderRadius:6,color:"#e74c3c",fontSize:14},children:f}),v&&e.jsx("div",{style:{background:"#d4edda",padding:12,borderRadius:6,color:"#155724",fontSize:14},children:t("settings.saved")}),e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:8},children:[e.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:t("settings.uiLanguage")}),e.jsxs("select",{value:u,onChange:n=>p(n.target.value),style:{padding:"10px 12px",borderRadius:6,border:"1px solid #ddd",fontSize:16,background:"#fff",outline:"none"},children:[e.jsx("option",{value:"en-US",children:t("settings.uiLanguageEn")}),e.jsx("option",{value:"es-ES",children:t("settings.uiLanguageEs")})]})]}),s&&e.jsxs(e.Fragment,{children:[e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:8},children:[e.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:t("settings.fontSize",{size:String(s.font_size)})}),e.jsx("input",{type:"range",min:12,max:36,value:s.font_size,onChange:n=>a({...s,font_size:Number(n.target.value)}),style:{width:"100%",cursor:"pointer"}}),e.jsxs("div",{style:{display:"flex",justifyContent:"space-between",fontSize:12,color:"#999"},children:[e.jsx("span",{children:"12px"}),e.jsx("span",{children:"36px"})]})]}),e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:8},children:[e.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:t("settings.fontStyle")}),e.jsxs("select",{value:s.font_style,onChange:n=>a({...s,font_style:n.target.value}),style:{padding:"10px 12px",borderRadius:6,border:"1px solid #ddd",fontSize:16,background:"#fff",outline:"none"},children:[e.jsx("option",{value:"sans-serif",children:t("settings.fontSans")}),e.jsx("option",{value:"serif",children:t("settings.fontSerif")}),e.jsx("option",{value:"monospace",children:t("settings.fontMonospace")})]})]}),e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:8},children:[e.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:t("settings.backgroundColor")}),e.jsx("div",{style:{display:"flex",gap:12,flexWrap:"wrap"},children:S.map(n=>e.jsx("button",{onClick:()=>a({...s,background_color:n.value}),style:{width:48,height:48,borderRadius:"50%",cursor:"pointer",display:"flex",alignItems:"center",justifyContent:"center",background:n.value,border:s.background_color===n.value?"3px solid #1a1a2e":"3px solid #ddd"},title:t(n.labelKey),children:s.background_color===n.value&&e.jsx("span",{style:{fontSize:20,fontWeight:700,color:n.value==="#ffffff"||n.value==="#f4e4c1"?"#1a1a2e":"#fff"},children:"✓"})},n.value))})]})]}),e.jsx("button",{onClick:b,disabled:o,style:{padding:"12px 24px",borderRadius:8,border:"none",background:"#1a1a2e",color:"#fff",fontSize:16,fontWeight:600,cursor:"pointer",opacity:o?.6:1,marginTop:8},children:t(o?"common.saving":"settings.saveSettings")})]})]})}export{W as SettingsPage};
+1
View File
@@ -0,0 +1 @@
import{X as i,j as e}from"./index-BQiEVoRj.js";function o({item:s,depth:a,onNavigate:t}){var c;return e.jsxs(e.Fragment,{children:[e.jsx("button",{type:"button",className:"toc-item",style:{paddingLeft:`${16+a*14}px`},onClick:()=>t(s.href),children:e.jsx("span",{className:"toc-item-title",children:s.label})}),(c=s.subitems)==null?void 0:c.map(r=>e.jsx(o,{item:r,depth:a+1,onNavigate:t},r.href))]})}function x({items:s,isOpen:a,onClose:t,onNavigate:c}){const{t:r}=i(),l=n=>{c(n),t()};return e.jsxs(e.Fragment,{children:[a&&e.jsx("div",{className:"toc-overlay",onClick:t,onKeyDown:n=>{n.key==="Escape"&&t()},role:"presentation"}),e.jsxs("aside",{className:`toc-drawer ${a?"toc-drawer--open":""}`,children:[e.jsxs("div",{className:"toc-header",children:[e.jsx("h2",{className:"toc-title",children:r("reader.tocTitle")}),e.jsx("button",{type:"button",className:"toc-close-btn",onClick:t,"aria-label":r("reader.closeTocAria"),children:e.jsxs("svg",{width:"20",height:"20",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("line",{x1:"18",y1:"6",x2:"6",y2:"18"}),e.jsx("line",{x1:"6",y1:"6",x2:"18",y2:"18"})]})})]}),e.jsx("nav",{className:"toc-list",children:s.length===0?e.jsx("p",{className:"toc-empty",children:r("reader.tocEmpty")}):s.map(n=>e.jsx(o,{item:n,depth:0,onNavigate:l},n.href))})]})]})}export{x as default};
+1
View File
@@ -0,0 +1 @@
import{a}from"./index-BQiEVoRj.js";const i={async getEBooks(){const{data:t}=await a.get("/books/ebooks/");return Array.isArray(t)?t:t.results??[]},async getEBook(t){const{data:o}=await a.get(`/books/ebooks/${t}/`);return o},async getEpubFile(t){const{data:o}=await a.get(`/books/ebooks/${t}/file/`,{responseType:"blob"});return o},async searchBooks(t={}){const{data:o}=await a.get("/books/",{params:t});return Array.isArray(o)?{count:o.length,results:o}:{count:o.count??0,results:o.results??[]}},async getBook(t){const{data:o}=await a.get(`/books/${t}/`);return o},async getGenres(){const{data:t}=await a.get("/books/genres/");return t},async getAuthors(){const{data:t}=await a.get("/books/authors/");return t},async uploadEBook(t,o,s,n){const e=new FormData;e.append("file",t),e.append("title",o),s&&e.append("author",s),n&&e.append("cover_image",n);const{data:r}=await a.post("/books/ebooks/",e,{headers:{"Content-Type":"multipart/form-data"}});return r},async deleteEBook(t){await a.delete(`/books/ebooks/${t}/`)},async enrichEBookMetadata(t){const{data:o}=await a.post(`/books/ebooks/${t}/enrich-metadata/`);return o},async processEBook(t){const{data:o}=await a.post(`/books/ebooks/${t}/process/`);return o},async getToc(t){const{data:o}=await a.get(`/books/ebooks/${t}/toc/`);return o},async getContent(t,o){const{data:s}=await a.get(`/books/ebooks/${t}/content/?page=${o}`);return s},async getProgress(t){const{data:o}=await a.get(`/books/ebooks/${t}/progress/`);return o},async updateProgress(t,o){const{data:s}=await a.patch(`/books/ebooks/${t}/progress/`,o);return s},async getSettings(){const{data:t}=await a.get("/books/settings/");return t},async updateSettings(t){const{data:o}=await a.patch("/books/settings/",t);return o}};export{i as b};
+1
View File
@@ -0,0 +1 @@
import{b as f}from"./index-BQiEVoRj.js";function i(t){if(t==null)return[];if(typeof t=="string")return[t];if(Array.isArray(t))return t.flatMap(i);if(typeof t=="object"){const s=t;if("detail"in s){const e=i(s.detail);if(e.length>0)return e}const r=[];for(const[e,o]of Object.entries(s))if(e!=="detail")for(const n of i(o))r.push(e==="non_field_errors"?n:`${e}: ${n}`);return r}return[]}function g(t,s="Something went wrong"){var r,e;if(f.isAxiosError(t)){const o=(r=t.response)==null?void 0:r.data;if(o!==void 0){const n=i(o);if(n.length>0)return n.join(". ")}return(e=t.response)!=null&&e.status&&t.message.startsWith("Request failed")?s:t.message||s}return t instanceof Error?t.message:s}export{g};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{r as n}from"./index-BQiEVoRj.js";function m(t){const[c,s]=n.useState(!1);return n.useEffect(()=>{const e=window.matchMedia(t);s(e.matches);const a=r=>s(r.matches);return e.addEventListener("change",a),()=>e.removeEventListener("change",a)},[t]),c}const h={md:"(max-width: 768px)"};export{h as B,m as u};
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cloud Reader</title>
<script type="module" crossorigin src="/assets/index-BQiEVoRj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bc1rr75j.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+10 -7
View File
@@ -11,20 +11,23 @@
"lint": "eslint ." "lint": "eslint ."
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.9",
"react-router-dom": "^7.1.0",
"pdfjs-dist": "^4.10.38",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.0", "react-i18n-lite": "^1.0.10",
"axios": "^1.7.9" "react-reader": "^2.0.15"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"jsdom": "^25.0.0",
"typescript": "~5.7.0", "typescript": "~5.7.0",
"vite": "^6.0.0", "vite": "^6.0.0",
"vitest": "^2.1.0", "vitest": "^2.1.0"
"@testing-library/react": "^16.2.0",
"@testing-library/jest-dom": "^6.6.3",
"jsdom": "^25.0.0"
} }
} }
+68
View File
@@ -0,0 +1,68 @@
import React, { lazy, Suspense } from "react";
import { BrowserRouter, Navigate, Route, Routes, useParams } from "react-router-dom";
import { AuthProvider, useAuth } from "./context/AuthContext";
import { I18nProvider } from "./i18n/I18nProvider";
import { ToastProvider } from "./hooks/useToast";
import { AnnotationsProvider } from "./context/AnnotationsContext";
import { useTranslation } from "react-i18n-lite";
const LibraryPage = lazy(() => import("./pages/Library").then((m) => ({ default: m.LibraryPage })));
const BookDetailPage = lazy(() => import("./pages/BookDetailPage").then((m) => ({ default: m.BookDetailPage })));
const AddBookPage = lazy(() => import("./pages/AddBook").then((m) => ({ default: m.AddBookPage })));
const SettingsPage = lazy(() => import("./pages/Settings").then((m) => ({ default: m.SettingsPage })));
const BookmarksNotesPage = lazy(() => import("./components/annotations/BookmarksNotesPage").then((m) => ({ default: m.BookmarksNotesPage })));
const ReadingPage = lazy(() => import("./pages/ReadingPage").then((m) => ({ default: m.default })));
const AuthPage = lazy(() => import("./pages/AuthPage"));
function LoadingFallback() {
const { t } = useTranslation();
return <div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", color: "#888", fontSize: 16 }}><p>{t("common.loading")}</p></div>;
}
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, loading } = useAuth();
if (loading) return <LoadingFallback />;
if (!isAuthenticated) return <Navigate to="/auth" replace />;
return <>{children}</>;
}
function ReaderRedirect() {
const { id } = useParams<{ id: string }>();
return <Navigate to={`/read/${id ?? ""}`} replace />;
}
function AppRoutes() {
const { isAuthenticated } = useAuth();
return (
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/auth" element={isAuthenticated ? <Navigate to="/" replace /> : <AuthPage />} />
<Route path="/" element={<ProtectedRoute><LibraryPage /></ProtectedRoute>} />
<Route path="/books/:id" element={<ProtectedRoute><BookDetailPage /></ProtectedRoute>} />
<Route path="/read/:id" element={<ProtectedRoute><ReadingPage /></ProtectedRoute>} />
<Route path="/reader/:id" element={<ProtectedRoute><ReaderRedirect /></ProtectedRoute>} />
<Route path="/add" element={<ProtectedRoute><AddBookPage /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
<Route path="/bookmarks-notes/:bookId?" element={<ProtectedRoute><BookmarksNotesPage /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
);
}
export default function App() {
return (
<BrowserRouter>
<I18nProvider>
<AuthProvider>
<ToastProvider>
<AnnotationsProvider>
<AppRoutes />
</AnnotationsProvider>
</ToastProvider>
</AuthProvider>
</I18nProvider>
</BrowserRouter>
);
}
+7 -74
View File
@@ -1,96 +1,29 @@
import api from "@/api/client"; import api from "@/api/client";
import type { import type {
Bookmark, Bookmark,
CreateBookmarkPayload, CreateMarkerPayload,
Note,
CreateNotePayload,
UpdateNotePayload,
PaginatedResponse, PaginatedResponse,
} from "@/types"; } from "@/types";
/** Fetch bookmarks for the current user, optionally filtered by book */
export async function fetchBookmarks( export async function fetchBookmarks(
bookId?: string ebookId?: string | number,
): Promise<PaginatedResponse<Bookmark>> { ): Promise<PaginatedResponse<Bookmark>> {
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (bookId) params.book = bookId; if (ebookId != null && ebookId !== "") params.ebook = String(ebookId);
const { data } = await api.get<PaginatedResponse<Bookmark>>( const { data } = await api.get<PaginatedResponse<Bookmark>>(
"/annotations/bookmarks/", "/annotations/bookmarks/",
{ params } { params },
); );
return data; return data;
} }
/** Create a new bookmark */ export async function createMarker(
export async function createBookmark( payload: CreateMarkerPayload,
payload: CreateBookmarkPayload
): Promise<Bookmark> { ): Promise<Bookmark> {
const { data } = await api.post<Bookmark>( const { data } = await api.post<Bookmark>("/annotations/bookmarks/", payload);
"/annotations/bookmarks/",
payload
);
return data; return data;
} }
/** Delete a bookmark by id */
export async function deleteBookmark(id: string): Promise<void> { export async function deleteBookmark(id: string): Promise<void> {
await api.delete(`/annotations/bookmarks/${id}/`); await api.delete(`/annotations/bookmarks/${id}/`);
} }
/** Batch delete bookmarks */
export async function batchDeleteBookmarks(
ids: string[]
): Promise<{ deleted: number }> {
const { data } = await api.delete<{ deleted: number }>(
"/annotations/bookmarks/batch-delete/",
{ data: { ids } }
);
return data;
}
/** Fetch notes for the current user, optionally filtered by book */
export async function fetchNotes(
bookId?: string
): Promise<PaginatedResponse<Note>> {
const params: Record<string, string> = {};
if (bookId) params.book = bookId;
const { data } = await api.get<PaginatedResponse<Note>>(
"/annotations/notes/",
{ params }
);
return data;
}
/** Create a new note */
export async function createNote(payload: CreateNotePayload): Promise<Note> {
const { data } = await api.post<Note>("/annotations/notes/", payload);
return data;
}
/** Update a note's content */
export async function updateNote(
id: string,
payload: UpdateNotePayload
): Promise<Note> {
const { data } = await api.patch<Note>(
`/annotations/notes/${id}/`,
payload
);
return data;
}
/** Delete a note by id */
export async function deleteNote(id: string): Promise<void> {
await api.delete(`/annotations/notes/${id}/`);
}
/** Batch delete notes */
export async function batchDeleteNotes(
ids: string[]
): Promise<{ deleted: number }> {
const { data } = await api.delete<{ deleted: number }>(
"/annotations/notes/batch-delete/",
{ data: { ids } }
);
return data;
}
+134
View File
@@ -0,0 +1,134 @@
import api from "./client";
import type {
BookDetail,
BookListItem,
BookSearchParams,
ContentResponse,
EBookDetail,
EBookListItem,
ReadingProgress,
ReadingSettings,
TocResponse,
} from "../types/book";
export const booksApi = {
async getEBooks(): Promise<EBookListItem[]> {
const { data } = await api.get<{ count: number; results: EBookListItem[] } | EBookListItem[]>("/books/ebooks/");
if (Array.isArray(data)) return data;
return data.results ?? [];
},
async getEBook(id: number): Promise<EBookDetail> {
const { data } = await api.get<EBookDetail>(`/books/ebooks/${id}/`);
return data;
},
async getEbookFile(id: number): Promise<Blob> {
const { data } = await api.get<Blob>(`/books/ebooks/${id}/file/`, {
responseType: "blob",
});
return data;
},
async getEpubFile(id: number): Promise<Blob> {
const { data } = await api.get<Blob>(`/books/ebooks/${id}/file/`, {
responseType: "blob",
});
return data;
},
async searchBooks(params: BookSearchParams = {}): Promise<{ count: number; results: BookListItem[] }> {
const { data } = await api.get<{ count: number; results: BookListItem[] } | BookListItem[]>("/books/", { params });
if (Array.isArray(data)) {
return { count: data.length, results: data };
}
return { count: data.count ?? 0, results: data.results ?? [] };
},
async getBook(id: number): Promise<BookDetail> {
const { data } = await api.get<BookDetail>(`/books/${id}/`);
return data;
},
async getGenres(): Promise<string[]> {
const { data } = await api.get<string[]>("/books/ebooks/genres/");
return data;
},
async getAuthors(): Promise<string[]> {
const { data } = await api.get<string[]>("/books/ebooks/authors/");
return data;
},
async searchEBooks(params: { q: string; page_size?: number }): Promise<EBookListItem[]> {
const { data } = await api.get<{ count: number; results: EBookListItem[] } | EBookListItem[]>(
"/books/ebooks/",
{ params: { q: params.q.trim() } },
);
const items = Array.isArray(data) ? data : data.results ?? [];
const limit = params.page_size ?? items.length;
return items.slice(0, limit);
},
async uploadEBook(
file: File,
title: string,
author: string,
coverImage?: File | null,
): Promise<EBookDetail> {
const formData = new FormData();
formData.append("file", file);
formData.append("title", title);
if (author) formData.append("author", author);
if (coverImage) formData.append("cover_image", coverImage);
const { data } = await api.post<EBookDetail>("/books/ebooks/", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return data;
},
async deleteEBook(id: number): Promise<void> {
await api.delete(`/books/ebooks/${id}/`);
},
async enrichEBookMetadata(id: number): Promise<EBookDetail> {
const { data } = await api.post<EBookDetail>(`/books/ebooks/${id}/enrich-metadata/`);
return data;
},
async processEBook(id: number): Promise<{ status: string }> {
const { data } = await api.post<{ status: string }>(`/books/ebooks/${id}/process/`);
return data;
},
async getToc(id: number): Promise<TocResponse> {
const { data } = await api.get<TocResponse>(`/books/ebooks/${id}/toc/`);
return data;
},
async getContent(id: number, page: number): Promise<ContentResponse> {
const { data } = await api.get<ContentResponse>(`/books/ebooks/${id}/content/?page=${page}`);
return data;
},
async getProgress(ebookId: number): Promise<ReadingProgress> {
const { data } = await api.get<ReadingProgress>(`/books/ebooks/${ebookId}/progress/`);
return data;
},
async updateProgress(ebookId: number, progressData: Partial<ReadingProgress>): Promise<ReadingProgress> {
const { data } = await api.patch<ReadingProgress>(`/books/ebooks/${ebookId}/progress/`, progressData);
return data;
},
async getSettings(): Promise<ReadingSettings> {
const { data } = await api.get<ReadingSettings>("/books/settings/");
return data;
},
async updateSettings(settingsData: Partial<ReadingSettings>): Promise<ReadingSettings> {
const { data } = await api.patch<ReadingSettings>("/books/settings/", settingsData);
return data;
},
};
+1 -1
View File
@@ -56,7 +56,7 @@ api.interceptors.response.use(
} catch { } catch {
localStorage.removeItem("access_token"); localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token"); localStorage.removeItem("refresh_token");
window.location.href = "/login"; window.location.href = "/auth";
return Promise.reject(error); return Promise.reject(error);
} finally { } finally {
isRefreshing = false; isRefreshing = false;

Some files were not shown because too many files have changed in this diff Show More