Compare commits

..
Author SHA1 Message Date
Marko 1b3b709ba5 feat: implement group EPUB upload and section splitting (US #29)
Backend:
- Create hermes Django app with models: ReadingGroup, GroupBook, Section, ReadingSchedule, MemberProgress
- EPUB section splitting service with automatic detection and reading time estimation
- Section recommendation engine for 4-week meeting schedule
- REST API endpoints for groups, books, sections, schedule, and member progress
- Manual section adjustment (merge/split) support

Frontend:
- GroupsPage: list/create reading groups
- GroupDetailPage: manage members, upload EPUB to group, view group books
- GroupBookPage: section breakdown with merge/split controls, reading schedule, member progress
- API client and TypeScript types for all group operations
- i18n keys for English and Spanish

Shared:
- Group-related types and API endpoint constants in packages/shared
2026-06-20 19:30:11 +00:00
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
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
231 changed files with 56581 additions and 1848 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
```
+37 -1
View File
@@ -14,7 +14,7 @@ cloud-reader/
│ │ └── annotations/ # Bookmarks and notes │ │ └── annotations/ # Bookmarks and notes
│ ├── manage.py │ ├── manage.py
│ └── requirements.txt │ └── requirements.txt
├── frontend/ # React + Vite + TypeScript (canonical frontend) ├── frontend/ # React + Vite + TypeScript (web frontend)
│ ├── src/ │ ├── src/
│ │ ├── api/ # API client (axios with JWT refresh) │ │ ├── api/ # API client (axios with JWT refresh)
│ │ ├── components/ # Reusable components │ │ ├── components/ # Reusable components
@@ -23,6 +23,23 @@ cloud-reader/
│ │ ├── pages/ # Route pages (Library, Reader, AddBook, Auth, Settings) │ │ ├── pages/ # Route pages (Library, Reader, AddBook, Auth, Settings)
│ │ └── types/ # TypeScript type definitions │ │ └── types/ # TypeScript type definitions
│ └── package.json │ └── 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 └── docker-compose.yml
``` ```
@@ -30,6 +47,9 @@ cloud-reader/
### Docker (recommended) ### Docker (recommended)
```bash ```bash
cp env.example .env
```
```bash
docker compose up --build docker compose up --build
``` ```
- **Frontend:** http://localhost:5173 - **Frontend:** http://localhost:5173
@@ -50,8 +70,24 @@ yarn install
yarn dev yarn dev
``` ```
### Mobile (Expo)
```bash
# From monorepo root — installs all workspaces including mobile
yarn install
# 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 ## Migration Notes
Consolidated from duplicate `api/` + `web/` into single `backend/` + `frontend/` canonical structure. 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. - `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. - `frontend/` kept as canonical; `web/` pages (Library, Reader, AddBook, Auth, Settings) merged in.
- `api/` and `web/` directories removed. - `api/` and `web/` directories removed.
- `mobile/` added as Expo React Native app with shared `@cloud-reader/shared` package.
+8
View File
@@ -6,3 +6,11 @@ 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
+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),
),
]
+25 -9
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(
+47 -31
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"]
+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,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),
),
]
+74 -27
View File
@@ -12,19 +12,6 @@ class ReadingStatus(models.TextChoices):
DNF = "dnf", "Did Not Finish" DNF = "dnf", "Did Not Finish"
class FontStyle(models.TextChoices):
SANS_SERIF = "sans-serif", "Sans Serif"
SERIF = "serif", "Serif"
MONOSPACE = "monospace", "Monospace"
class BackgroundColor(models.TextChoices):
WHITE = "#ffffff", "White"
SEPIA = "#f4e4c1", "Sepia"
DARK = "#1a1a2e", "Dark"
GREEN = "#c7edcc", "Green"
class Book(models.Model): class Book(models.Model):
title = models.CharField(max_length=512, db_index=True) title = models.CharField(max_length=512, db_index=True)
author = models.CharField(max_length=256, blank=True, default="", db_index=True) author = models.CharField(max_length=256, blank=True, default="", db_index=True)
@@ -49,8 +36,12 @@ class Book(models.Model):
class EBook(models.Model): class EBook(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ebooks") user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ebooks")
title = models.CharField(max_length=512) title = models.CharField(max_length=512, db_index=True)
author = models.CharField(max_length=256, blank=True, default="") 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/") file = models.FileField(upload_to="ebooks/%Y/%m/%d/")
cover_image = models.ImageField(upload_to="ebook_covers/%Y/%m/%d/", blank=True, null=True) cover_image = models.ImageField(upload_to="ebook_covers/%Y/%m/%d/", blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
@@ -70,6 +61,24 @@ class EBook(models.Model):
return Path(self.file.name).name if self.file else "" 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) @receiver(post_delete, sender=EBook)
def _auto_delete_ebook_file(sender, instance, **kwargs): def _auto_delete_ebook_file(sender, instance, **kwargs):
if instance.file: if instance.file:
@@ -78,11 +87,34 @@ def _auto_delete_ebook_file(sender, instance, **kwargs):
instance.cover_image.delete(save=False) 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): class ReadingProgress(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reading_progress") 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") ebook = models.OneToOneField(EBook, on_delete=models.CASCADE, related_name="reading_progress")
current_position = models.FloatField(default=0.0) current_position = models.FloatField(default=0.0)
last_page = models.IntegerField(default=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) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
@@ -93,17 +125,32 @@ class ReadingProgress(models.Model):
def __str__(self): def __str__(self):
return f"{self.ebook.title} - {self.current_position:.1f}%" 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).
class ReadingSettings(models.Model): Returns (instance, applied) where applied is True if the update was applied.
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reading_settings") """
font_size = models.IntegerField(default=18) if client_updated_at and self.updated_at:
font_style = models.CharField(max_length=20, choices=FontStyle.choices, default=FontStyle.SANS_SERIF.value) try:
background_color = models.CharField(max_length=7, choices=BackgroundColor.choices, default=BackgroundColor.WHITE.value) from django.utils.timezone import is_naive, make_aware
updated_at = models.DateTimeField(auto_now=True) 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
class Meta: self.current_position = position
db_table = "books_reading_settings" self.last_page = last_page
verbose_name_plural = "reading settings" self.device_id = device_id
self.device_name = device_name
def __str__(self): self.version += 1
return f"Settings for {self.user}" self.save(update_fields=[
"current_position", "last_page",
"device_id", "device_name", "version", "updated_at",
])
return self, True
+135 -24
View File
@@ -1,6 +1,46 @@
from rest_framework import serializers from rest_framework import serializers
from apps.books.models import Book, EBook, FontStyle, BackgroundColor, ReadingProgress, ReadingSettings, ReadingStatus 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
logger = logging.getLogger(__name__)
class BookReadingSettingsSerializer(serializers.ModelSerializer):
font_style = serializers.CharField(source="font_family")
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
class BookChapterSerializer(serializers.ModelSerializer):
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):
@@ -29,11 +69,20 @@ class BookSerializer(serializers.ModelSerializer):
class EBookListSerializer(serializers.ModelSerializer): class EBookListSerializer(serializers.ModelSerializer):
filename = serializers.CharField(read_only=True) filename = serializers.CharField(read_only=True)
format = serializers.CharField(read_only=True)
progress = serializers.SerializerMethodField() progress = serializers.SerializerMethodField()
started = serializers.SerializerMethodField()
subjects = serializers.SerializerMethodField()
class Meta: class Meta:
model = EBook model = EBook
fields = ["id", "title", "author", "filename", "cover_image", "created_at", "progress"] 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): def get_progress(self, obj):
try: try:
@@ -41,15 +90,32 @@ class EBookListSerializer(serializers.ModelSerializer):
except ReadingProgress.DoesNotExist: except ReadingProgress.DoesNotExist:
return None 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): class EBookDetailSerializer(serializers.ModelSerializer):
filename = serializers.CharField(read_only=True) filename = serializers.CharField(read_only=True)
format = serializers.CharField(read_only=True)
file_url = serializers.SerializerMethodField() file_url = serializers.SerializerMethodField()
progress = serializers.SerializerMethodField() progress = serializers.SerializerMethodField()
metadata = serializers.JSONField(source="metadata_json", read_only=True)
class Meta: class Meta:
model = EBook model = EBook
fields = ["id", "title", "author", "filename", "file_url", "cover_image", "created_at", "updated_at", "progress"] 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): def get_file_url(self, obj):
request = self.context.get("request") request = self.context.get("request")
@@ -60,7 +126,11 @@ class EBookDetailSerializer(serializers.ModelSerializer):
def get_progress(self, obj): def get_progress(self, obj):
try: try:
rp = obj.reading_progress rp = obj.reading_progress
return {"current_position": rp.current_position, "last_page": rp.last_page} return {
"current_position": rp.current_position,
"last_page": rp.last_page,
"epub_location": rp.epub_location,
}
except ReadingProgress.DoesNotExist: except ReadingProgress.DoesNotExist:
return None return None
@@ -81,15 +151,35 @@ class EBookUploadSerializer(serializers.ModelSerializer):
return value return value
def create(self, validated_data): def create(self, validated_data):
import os
validated_data["user"] = self.context["request"].user validated_data["user"] = self.context["request"].user
return super().create(validated_data) 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 ReadingProgressSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = ReadingProgress model = ReadingProgress
fields = ["current_position", "last_page"] fields = [
extra_kwargs = {"current_position": {"required": True, "min_value": 0.0, "max_value": 100.0}} "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): def validate_current_position(self, value):
if value < 0.0 or value > 100.0: if value < 0.0 or value > 100.0:
@@ -97,24 +187,45 @@ class ReadingProgressSerializer(serializers.ModelSerializer):
return value return value
class ReadingSettingsSerializer(serializers.ModelSerializer): 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: class Meta:
model = ReadingSettings model = DownloadRecord
fields = ["font_size", "font_style", "background_color"] fields = [
"id", "ebook_id", "ebook_title", "author", "filename", "file_url",
"file_size", "cover_image", "format", "downloaded_at", "progress",
]
def validate_font_size(self, value): def get_filename(self, obj):
if value < 12 or value > 36: return obj.ebook.filename()
raise serializers.ValidationError("Font size must be between 12 and 36.")
return value
def validate_font_style(self, value): def get_file_url(self, obj):
valid = [s.value for s in FontStyle] request = self.context.get("request")
if value not in valid: if request and obj.ebook.file:
raise serializers.ValidationError(f"Font style must be one of: {', '.join(valid)}") return request.build_absolute_uri(obj.ebook.file.url)
return value return ""
def validate_background_color(self, value): def get_progress(self, obj):
valid = [c.value for c in BackgroundColor] try:
if value not in valid: rp = obj.ebook.reading_progress
raise serializers.ValidationError(f"Background color must be one of: {', '.join(valid)}") return {
return value "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
+3 -6
View File
@@ -1,16 +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, EBookViewSet, ReadingSettingsViewSet 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")
ebook_router = DefaultRouter()
ebook_router.register(r"ebooks", EBookViewSet, basename="ebook")
urlpatterns = [ urlpatterns = [
path("settings/", book_reading_settings_view, name="book-settings"),
path("", include(router.urls)), path("", include(router.urls)),
path("", include(ebook_router.urls)),
path("settings/", ReadingSettingsViewSet.as_view({"get": "list", "patch": "partial_update"}), name="reading-settings"),
] ]
+140 -54
View File
@@ -4,21 +4,25 @@ import logging
from typing import Any from typing import Any
from django.db.models import QuerySet, Q from django.db.models import QuerySet, Q
from django.http import FileResponse
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import parsers, permissions, status, viewsets from rest_framework import parsers, permissions, status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from apps.books.models import Book, BookChapter, EBook, ReadingProgress, ReadingSettings from apps.books.models import Book, BookChapter, DownloadRecord, EBook, ReadingProgress
from apps.books.serializers import ( from apps.books.serializers import (
BookDetailSerializer, BookListSerializer, BookSerializer, BookChapterSerializer, BookDetailSerializer, BookListSerializer, BookReadingSettingsSerializer,
BookChapterSerializer, EBookContentSerializer, EBookDetailSerializer, BookSerializer, DownloadRecordSerializer, EBookContentSerializer, EBookDetailSerializer,
EBookListSerializer, EBookTocSerializer, EBookUploadSerializer, EBookListSerializer, EBookTocSerializer, EBookUploadSerializer,
ReadingProgressSerializer, ReadingSettingsSerializer, 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__) logger = logging.getLogger(__name__)
@@ -56,6 +60,23 @@ class BookViewSet(viewsets.ModelViewSet):
author_list = Book.objects.values_list("author", flat=True).distinct().order_by("author") author_list = Book.objects.values_list("author", flat=True).distinct().order_by("author")
return Response([a for a in author_list if a]) 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): class IsEBookOwner(permissions.BasePermission):
def has_object_permission(self, request: Request, view: object, obj: EBook) -> bool: def has_object_permission(self, request: Request, view: object, obj: EBook) -> bool:
@@ -76,7 +97,30 @@ class EBookViewSet(viewsets.ModelViewSet):
return EBookDetailSerializer return EBookDetailSerializer
def get_queryset(self): def get_queryset(self):
return EBook.objects.filter(user=self.request.user).select_related("reading_progress", "user") 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"]) @action(detail=True, methods=["get", "patch"])
def progress(self, request: Request, pk: int | None = None) -> Response: def progress(self, request: Request, pk: int | None = None) -> Response:
@@ -90,6 +134,22 @@ class EBookViewSet(viewsets.ModelViewSet):
serializer.save() serializer.save()
return Response(serializer.data) 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"]) @action(detail=True, methods=["post"])
def process(self, request: Request, pk: int | None = None) -> Response: def process(self, request: Request, pk: int | None = None) -> Response:
"""Trigger e-book processing: metadata extraction, TOC building, page counting.""" """Trigger e-book processing: metadata extraction, TOC building, page counting."""
@@ -98,33 +158,36 @@ class EBookViewSet(viewsets.ModelViewSet):
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST) return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
try: try:
from apps.books.services import process_ebook from apps.books.services.process_ebook import apply_processing_to_ebook
file_path = ebook.file.path result = apply_processing_to_ebook(ebook)
result = process_ebook(file_path, original_filename=ebook.filename()) return Response(result)
# Update ebook with extracted data
ebook.format = result.get("format", ebook.format)
ebook.page_count = result.get("page_count", 0)
ebook.metadata_json = result.get("metadata", {})
ebook.save(update_fields=["format", "page_count", "metadata_json", "updated_at"])
# Store chapters in DB
raw_toc: list[dict[str, Any]] = result.get("toc", [])
BookChapter.objects.filter(ebook=ebook).delete()
_store_chapters(ebook, raw_toc)
return Response({
"format": ebook.format,
"page_count": ebook.page_count,
"metadata": ebook.metadata_json,
"toc_count": len(raw_toc),
"status": "processed",
})
except Exception as exc: except Exception as exc:
logger.exception("Failed to process ebook %s", ebook.id) logger.exception("Failed to process ebook %s", ebook.id)
return Response({"error": f"Processing failed: {exc}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) 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"]) @action(detail=True, methods=["get"])
def toc(self, request: Request, pk: int | None = None) -> Response: def toc(self, request: Request, pk: int | None = None) -> Response:
"""Return hierarchical table of contents.""" """Return hierarchical table of contents."""
@@ -169,20 +232,47 @@ class EBookViewSet(viewsets.ModelViewSet):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
return Response(serializer.data) 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)
def _store_chapters(ebook: EBook, toc: list[dict[str, Any]], parent_index: int = 0) -> None: download, created = DownloadRecord.objects.get_or_create(
"""Recursively store TOC entries as BookChapter records.""" user=request.user,
for idx, entry in enumerate(toc):
BookChapter.objects.create(
ebook=ebook, ebook=ebook,
title=entry.get("title", "Untitled"), defaults={"file_size": ebook.file.size if ebook.file else 0},
index=parent_index + idx,
href=entry.get("href", ""),
children=entry.get("children", []),
) )
children = entry.get("children", []) if not created:
if children: download.file_size = ebook.file.size if ebook.file else 0
_store_chapters(ebook, children, parent_index + idx + 1) 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: def _fetch_chapter_content(ebook: EBook, chapter: BookChapter) -> str:
@@ -220,21 +310,17 @@ def _fetch_epub_chapter_content(file_path: str, chapter: BookChapter) -> str:
return "" return ""
class ReadingSettingsViewSet(viewsets.GenericViewSet): @api_view(["GET", "PATCH"])
permission_classes = [IsAuthenticated] @permission_classes([IsAuthenticated])
serializer_class = ReadingSettingsSerializer 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)
def get_queryset(self): if request.method == "GET":
return ReadingSettings.objects.filter(user=self.request.user) serializer = BookReadingSettingsSerializer(settings)
def list(self, request: Request) -> Response:
settings_obj, _created = ReadingSettings.objects.get_or_create(user=request.user)
serializer = self.get_serializer(settings_obj)
return Response(serializer.data) return Response(serializer.data)
def partial_update(self, request: Request) -> Response: serializer = BookReadingSettingsSerializer(settings, data=request.data, partial=True)
settings_obj, _created = ReadingSettings.objects.get_or_create(user=request.user) serializer.is_valid(raise_exception=True)
serializer = self.get_serializer(settings_obj, data=request.data, partial=True) serializer.save()
serializer.is_valid(raise_exception=True) return Response(serializer.data)
serializer.save()
return Response(serializer.data)
View File
+46
View File
@@ -0,0 +1,46 @@
from django.contrib import admin
from apps.groups.models import (
GroupBook,
MemberProgress,
ReadingGroup,
ReadingGroupMembership,
ReadingSchedule,
Section,
)
@admin.register(ReadingGroup)
class ReadingGroupAdmin(admin.ModelAdmin):
list_display = ["name", "admin", "created_at"]
search_fields = ["name", "admin__email"]
@admin.register(ReadingGroupMembership)
class ReadingGroupMembershipAdmin(admin.ModelAdmin):
list_display = ["user", "group", "role", "joined_at"]
list_filter = ["role", "group"]
@admin.register(GroupBook)
class GroupBookAdmin(admin.ModelAdmin):
list_display = ["title", "group", "status", "uploaded_by", "created_at"]
list_filter = ["status"]
search_fields = ["title", "group__name"]
@admin.register(Section)
class SectionAdmin(admin.ModelAdmin):
list_display = ["title", "group_book", "order", "estimated_reading_minutes"]
ordering = ["group_book", "order"]
@admin.register(ReadingSchedule)
class ReadingScheduleAdmin(admin.ModelAdmin):
list_display = ["group_book", "meeting_number", "week_date"]
ordering = ["group_book", "meeting_number"]
@admin.register(MemberProgress)
class MemberProgressAdmin(admin.ModelAdmin):
list_display = ["user", "group_book", "current_section", "updated_at"]
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class GroupsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.groups"
verbose_name = "Reading Groups"
@@ -0,0 +1,154 @@
# Generated by Django 5.1.7 on 2026-06-20 19:21
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('books', '0003_readingprogress_epub_location'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ReadingGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(db_index=True, max_length=256)),
('description', models.TextField(blank=True, default='')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('admin', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='administered_groups', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Reading Group',
'verbose_name_plural': 'Reading Groups',
'db_table': 'groups_reading_group',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='GroupBook',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(db_index=True, max_length=512)),
('status', models.CharField(choices=[('active', 'Active'), ('replaced', 'Replaced')], db_index=True, default='active', max_length=16)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('ebook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_books', to='books.ebook')),
('uploaded_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uploaded_group_books', to=settings.AUTH_USER_MODEL)),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_books', to='groups.readinggroup')),
],
options={
'verbose_name': 'Group Book',
'verbose_name_plural': 'Group Books',
'db_table': 'groups_group_book',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ReadingGroupMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('admin', 'Admin'), ('member', 'Member')], default='member', max_length=16)),
('joined_at', models.DateTimeField(auto_now_add=True)),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='groups.readinggroup')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Reading Group Membership',
'verbose_name_plural': 'Reading Group Memberships',
'db_table': 'groups_membership',
},
),
migrations.AddField(
model_name='readinggroup',
name='members',
field=models.ManyToManyField(related_name='reading_groups', through='groups.ReadingGroupMembership', to=settings.AUTH_USER_MODEL),
),
migrations.CreateModel(
name='ReadingSchedule',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('meeting_number', models.PositiveIntegerField()),
('week_date', models.DateField()),
('section_ids', models.JSONField(default=list, help_text='Ordered list of Section IDs for this meeting')),
('created_at', models.DateTimeField(auto_now_add=True)),
('group_book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='schedules', to='groups.groupbook')),
],
options={
'verbose_name': 'Reading Schedule',
'verbose_name_plural': 'Reading Schedules',
'db_table': 'groups_reading_schedule',
'ordering': ['meeting_number'],
},
),
migrations.CreateModel(
name='Section',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=512)),
('order', models.PositiveIntegerField(db_index=True)),
('start_chapter_index', models.PositiveIntegerField(default=0)),
('end_chapter_index', models.PositiveIntegerField(default=0)),
('estimated_reading_minutes', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('group_book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='groups.groupbook')),
],
options={
'verbose_name': 'Section',
'verbose_name_plural': 'Sections',
'db_table': 'groups_section',
'ordering': ['order'],
},
),
migrations.CreateModel(
name='MemberProgress',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('completed_sections', models.JSONField(default=list, help_text='Ordered list of completed Section IDs')),
('updated_at', models.DateTimeField(auto_now=True)),
('group_book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_progress', to='groups.groupbook')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_progress', to=settings.AUTH_USER_MODEL)),
('current_section', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='current_readers', to='groups.section')),
],
options={
'verbose_name': 'Member Progress',
'verbose_name_plural': 'Member Progress',
'db_table': 'groups_member_progress',
},
),
migrations.AddIndex(
model_name='groupbook',
index=models.Index(fields=['group', 'status'], name='groups_grou_group_i_afdbf7_idx'),
),
migrations.AlterUniqueTogether(
name='readinggroupmembership',
unique_together={('user', 'group')},
),
migrations.AddIndex(
model_name='readingschedule',
index=models.Index(fields=['group_book', 'meeting_number'], name='groups_read_group_b_2fc416_idx'),
),
migrations.AlterUniqueTogether(
name='readingschedule',
unique_together={('group_book', 'meeting_number')},
),
migrations.AddIndex(
model_name='section',
index=models.Index(fields=['group_book', 'order'], name='groups_sect_group_b_e2810a_idx'),
),
migrations.AlterUniqueTogether(
name='section',
unique_together={('group_book', 'order')},
),
migrations.AlterUniqueTogether(
name='memberprogress',
unique_together={('user', 'group_book')},
),
]
+161
View File
@@ -0,0 +1,161 @@
from django.conf import settings
from django.db import models
from apps.books.models import EBook
class ReadingGroup(models.Model):
"""A reading group with an admin and members."""
name = models.CharField(max_length=256, db_index=True)
description = models.TextField(blank=True, default="")
admin = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="administered_groups",
)
members = models.ManyToManyField(
settings.AUTH_USER_MODEL,
through="ReadingGroupMembership",
related_name="reading_groups",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "groups_reading_group"
verbose_name = "Reading Group"
verbose_name_plural = "Reading Groups"
ordering = ["-created_at"]
def __str__(self) -> str:
return self.name
class ReadingGroupMembership(models.Model):
class Role(models.TextChoices):
ADMIN = "admin", "Admin"
MEMBER = "member", "Member"
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
group = models.ForeignKey(ReadingGroup, on_delete=models.CASCADE, related_name="memberships")
role = models.CharField(max_length=16, choices=Role.choices, default=Role.MEMBER)
joined_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "groups_membership"
unique_together = [("user", "group")]
verbose_name = "Reading Group Membership"
verbose_name_plural = "Reading Group Memberships"
def __str__(self) -> str:
return f"{self.user} in {self.group.name} ({self.role})"
class GroupBook(models.Model):
"""Links an uploaded EBook to a reading group."""
class Status(models.TextChoices):
ACTIVE = "active", "Active"
REPLACED = "replaced", "Replaced"
group = models.ForeignKey(
ReadingGroup, on_delete=models.CASCADE, related_name="group_books"
)
ebook = models.ForeignKey(
EBook, on_delete=models.CASCADE, related_name="group_books"
)
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="uploaded_group_books"
)
title = models.CharField(max_length=512, db_index=True)
status = models.CharField(
max_length=16, choices=Status.choices, default=Status.ACTIVE, db_index=True
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "groups_group_book"
verbose_name = "Group Book"
verbose_name_plural = "Group Books"
ordering = ["-created_at"]
indexes = [models.Index(fields=["group", "status"])]
def __str__(self) -> str:
return f"{self.group.name}{self.title}"
class Section(models.Model):
"""A section/chunk of a GroupBook for group reading."""
group_book = models.ForeignKey(
GroupBook, on_delete=models.CASCADE, related_name="sections"
)
title = models.CharField(max_length=512)
order = models.PositiveIntegerField(db_index=True)
start_chapter_index = models.PositiveIntegerField(default=0)
end_chapter_index = models.PositiveIntegerField(default=0)
estimated_reading_minutes = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "groups_section"
verbose_name = "Section"
verbose_name_plural = "Sections"
ordering = ["order"]
unique_together = [("group_book", "order")]
indexes = [models.Index(fields=["group_book", "order"])]
def __str__(self) -> str:
return f"{self.group_book.title} — Section {self.order}: {self.title}"
class ReadingSchedule(models.Model):
"""Reading schedule mapping sections to weekly meetings."""
group_book = models.ForeignKey(
GroupBook, on_delete=models.CASCADE, related_name="schedules"
)
meeting_number = models.PositiveIntegerField()
week_date = models.DateField()
section_ids = models.JSONField(default=list, help_text="Ordered list of Section IDs for this meeting")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "groups_reading_schedule"
verbose_name = "Reading Schedule"
verbose_name_plural = "Reading Schedules"
ordering = ["meeting_number"]
unique_together = [("group_book", "meeting_number")]
indexes = [models.Index(fields=["group_book", "meeting_number"])]
def __str__(self) -> str:
return f"{self.group_book.title} — Meeting {self.meeting_number}"
class MemberProgress(models.Model):
"""Tracks a member's progress through sections of a group book."""
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="group_progress"
)
group_book = models.ForeignKey(
GroupBook, on_delete=models.CASCADE, related_name="member_progress"
)
current_section = models.ForeignKey(
Section, on_delete=models.SET_NULL, null=True, blank=True, related_name="current_readers"
)
completed_sections = models.JSONField(
default=list, help_text="Ordered list of completed Section IDs"
)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "groups_member_progress"
verbose_name = "Member Progress"
verbose_name_plural = "Member Progress"
unique_together = [("user", "group_book")]
def __str__(self) -> str:
return f"{self.user}{self.group_book.title}"
+185
View File
@@ -0,0 +1,185 @@
from __future__ import annotations
from rest_framework import serializers
from apps.groups.models import (
GroupBook,
MemberProgress,
ReadingGroup,
ReadingGroupMembership,
ReadingSchedule,
Section,
)
from apps.books.serializers import EBookListSerializer
class ReadingGroupMembershipSerializer(serializers.ModelSerializer):
user_email = serializers.CharField(source="user.email", read_only=True)
user_username = serializers.CharField(source="user.username", read_only=True)
class Meta:
model = ReadingGroupMembership
fields = [
"id", "user", "user_email", "user_username",
"role", "joined_at",
]
read_only_fields = ["id", "joined_at"]
class ReadingGroupListSerializer(serializers.ModelSerializer):
member_count = serializers.SerializerMethodField()
admin_email = serializers.CharField(source="admin.email", read_only=True)
class Meta:
model = ReadingGroup
fields = [
"id", "name", "description", "admin", "admin_email",
"member_count", "created_at", "updated_at",
]
def get_member_count(self, obj: ReadingGroup) -> int:
return obj.memberships.count()
class ReadingGroupDetailSerializer(serializers.ModelSerializer):
admin_email = serializers.CharField(source="admin.email", read_only=True)
members = ReadingGroupMembershipSerializer(source="memberships", many=True, read_only=True)
class Meta:
model = ReadingGroup
fields = [
"id", "name", "description", "admin", "admin_email",
"members", "created_at", "updated_at",
]
class ReadingGroupCreateSerializer(serializers.ModelSerializer):
class Meta:
model = ReadingGroup
fields = ["name", "description"]
class AddMemberSerializer(serializers.Serializer):
user_id = serializers.IntegerField()
def validate_user_id(self, value: int) -> int:
from django.conf import settings
User = settings.AUTH_USER_MODEL
if not User.objects.filter(id=value).exists():
raise serializers.ValidationError("User not found.")
return value
class SectionSerializer(serializers.ModelSerializer):
class Meta:
model = Section
fields = [
"id", "title", "order", "start_chapter_index",
"end_chapter_index", "estimated_reading_minutes", "created_at",
]
read_only_fields = ["id", "created_at"]
class SectionAdjustSerializer(serializers.Serializer):
"""Payload for manual section adjustments (merge/split)."""
operation = serializers.ChoiceField(choices=["merge", "split"])
section_ids = serializers.ListField(
child=serializers.IntegerField(), min_length=1,
help_text="For merge: list of section IDs to merge. For split: [section_id] to split."
)
split_at = serializers.IntegerField(
required=False, default=2, min_value=2,
help_text="Number of new sections when splitting"
)
class ReadingScheduleSerializer(serializers.ModelSerializer):
section_details = serializers.SerializerMethodField()
class Meta:
model = ReadingSchedule
fields = [
"id", "meeting_number", "week_date", "section_ids",
"section_details", "created_at",
]
read_only_fields = ["id", "created_at"]
def get_section_details(self, obj: ReadingSchedule) -> list[dict]:
sections = Section.objects.filter(
id__in=obj.section_ids, group_book=obj.group_book
).order_by("order")
return SectionSerializer(sections, many=True).data
class MemberProgressSerializer(serializers.ModelSerializer):
user_email = serializers.CharField(source="user.email", read_only=True)
user_username = serializers.CharField(source="user.username", read_only=True)
current_section_title = serializers.SerializerMethodField()
class Meta:
model = MemberProgress
fields = [
"id", "user", "user_email", "user_username",
"current_section", "current_section_title",
"completed_sections", "updated_at",
]
read_only_fields = ["id", "updated_at"]
def get_current_section_title(self, obj: MemberProgress) -> str | None:
if obj.current_section:
return obj.current_section.title
return None
class GroupBookListSerializer(serializers.ModelSerializer):
ebook = EBookListSerializer(read_only=True)
uploaded_by_email = serializers.CharField(source="uploaded_by.email", read_only=True)
section_count = serializers.SerializerMethodField()
class Meta:
model = GroupBook
fields = [
"id", "title", "status", "ebook", "uploaded_by",
"uploaded_by_email", "section_count", "created_at", "updated_at",
]
def get_section_count(self, obj: GroupBook) -> int:
return obj.sections.count()
class GroupBookDetailSerializer(serializers.ModelSerializer):
ebook = EBookListSerializer(read_only=True)
uploaded_by_email = serializers.CharField(source="uploaded_by.email", read_only=True)
sections = SectionSerializer(many=True, read_only=True)
schedules = ReadingScheduleSerializer(source="schedules", many=True, read_only=True)
class Meta:
model = GroupBook
fields = [
"id", "group", "title", "status", "ebook",
"uploaded_by", "uploaded_by_email",
"sections", "schedules", "created_at", "updated_at",
]
class GroupBookCreateSerializer(serializers.Serializer):
ebook_id = serializers.IntegerField()
title = serializers.CharField(max_length=512, required=False)
def validate_ebook_id(self, value: int) -> int:
from apps.books.models import EBook
if not EBook.objects.filter(id=value).exists():
raise serializers.ValidationError("EBook not found.")
# Check format is EPUB
ebook = EBook.objects.get(id=value)
if ebook.format != "epub":
raise serializers.ValidationError("Only EPUB format is supported for group books.")
return value
def validate(self, data: dict) -> dict:
request = self.context.get("request")
if request and not data.get("title"):
from apps.books.models import EBook
ebook = EBook.objects.get(id=data["ebook_id"])
data["title"] = ebook.title
return data
@@ -0,0 +1,241 @@
"""Automatic section detection and meeting recommendation for group books."""
from __future__ import annotations
import logging
from datetime import date, timedelta
from typing import Any
from django.db.models import QuerySet
from apps.books.models import BookChapter, EBook
from apps.groups.models import GroupBook, ReadingSchedule, Section
logger = logging.getLogger(__name__)
WORDS_PER_MINUTE = 250 # Average reading speed
def _fetch_chapter_text(ebook: EBook, chapter: BookChapter) -> str:
"""Extract plain text from a chapter for word counting."""
try:
from ebooklib import epub
from bs4 import BeautifulSoup
except ImportError:
return ""
try:
book = epub.read_epub(ebook.file.path)
href = chapter.href or ""
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")
body = soup.find("body")
if body:
return body.get_text(separator=" ", strip=True)
return soup.get_text(separator=" ", strip=True)
return ""
except Exception:
logger.exception("Failed to extract text for chapter %s", chapter.id)
return ""
def _estimate_reading_minutes(text: str) -> int:
"""Estimate reading time based on word count at WORDS_PER_MINUTE."""
word_count = len(text.split())
if word_count == 0:
return 1
return max(1, round(word_count / WORDS_PER_MINUTE))
def detect_sections(group_book: GroupBook) -> list[dict[str, Any]]:
"""Auto-detect sections from a GroupBook's chapters.
Groups consecutive chapters into logical sections based on TOC structure.
Top-level TOC entries become sections; if there are very few (< 3),
groups of ~5 chapters become sections instead.
"""
ebook = group_book.ebook
chapters = list(
BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook")
)
if not chapters:
return []
top_level = [ch for ch in chapters if not ch.children or len(ch.children) == 0]
has_children = [ch for ch in chapters if ch.children and len(ch.children) > 0]
sections: list[dict[str, Any]] = []
if len(has_children) >= 3:
# Use TOC structure: each top-level chapter (with children) is a section
for idx, ch in enumerate(has_children):
# Find all child chapters belonging to this parent
child_indices = _collect_child_indices(chapters, ch, idx)
start_idx = ch.index
end_idx = child_indices[-1] if child_indices else start_idx
section_chapters = [c for c in chapters if start_idx <= c.index <= end_idx]
total_text = ""
for sc in section_chapters:
total_text += " " + _fetch_chapter_text(ebook, sc)
sections.append({
"title": ch.title,
"order": idx + 1,
"start_chapter_index": start_idx,
"end_chapter_index": end_idx + 1,
"estimated_reading_minutes": _estimate_reading_minutes(total_text),
})
else:
# Group chapters into chunks of ~5
chunk_size = max(1, len(top_level) // 6 if len(top_level) > 6 else 5)
chunk_size = max(3, min(chunk_size, 10))
group_start = 0
section_order = 1
total = len(top_level) or len(chapters)
source = top_level or chapters
while group_start < total:
group_end = min(group_start + chunk_size, total)
chunk = source[group_start:group_end]
total_text = ""
for ch in chunk:
total_text += " " + _fetch_chapter_text(ebook, ch)
first_title = chunk[0].title if chunk else "Section"
last_title = chunk[-1].title if len(chunk) > 1 else ""
title = f"{first_title}" if not last_title or first_title == last_title else f"{first_title}{last_title}"
sections.append({
"title": title,
"order": section_order,
"start_chapter_index": chunk[0].index,
"end_chapter_index": chunk[-1].index + 1,
"estimated_reading_minutes": _estimate_reading_minutes(total_text),
})
group_start = group_end
section_order += 1
return sections
def _collect_child_indices(chapters: list[BookChapter], parent: BookChapter, parent_idx: int) -> list[int]:
"""Collect indices of all chapters that are children of the given parent TOC entry."""
indices: list[int] = [parent.index]
child_hrefs: set[str] = set()
for child in parent.children:
if isinstance(child, dict):
child_hrefs.add(child.get("href", ""))
elif hasattr(child, "href"):
child_hrefs.add(getattr(child, "href", ""))
for ch in chapters:
if ch.index == parent.index:
continue
if ch.href in child_hrefs or any(
ch.href.endswith(h) or h.endswith(ch.href) for h in child_hrefs
):
indices.append(ch.index)
# Also include chapters between this parent and the next parent
if parent_idx + 1 < len(chapters):
next_parent = chapters[parent_idx + 1]
for ch in chapters:
if parent.index < ch.index < next_parent.index:
indices.append(ch.index)
return sorted(set(indices))
def apply_sections(group_book: GroupBook) -> list[Section]:
"""Detect sections and persist them to the database, replacing existing ones."""
Section.objects.filter(group_book=group_book).delete()
sections_data = detect_sections(group_book)
created: list[Section] = []
for data in sections_data:
section = Section.objects.create(
group_book=group_book,
title=data["title"],
order=data["order"],
start_chapter_index=data["start_chapter_index"],
end_chapter_index=data["end_chapter_index"],
estimated_reading_minutes=data["estimated_reading_minutes"],
)
created.append(section)
return created
def recommend_meetings(group_book: GroupBook, num_meetings: int = 4) -> list[dict[str, Any]]:
"""Recommend which sections to assign to each weekly meeting.
Distributes sections across meetings, trying to balance total reading time.
Returns a list of meeting assignments ready for schedule creation.
"""
sections = list(
Section.objects.filter(group_book=group_book).order_by("order")
)
if not sections:
return []
# Calculate total minutes to distribute
total_minutes = sum(s.estimated_reading_minutes for s in sections)
target_per_meeting = total_minutes / num_meetings
meetings: list[dict[str, Any]] = []
current_meeting: list[int] = []
current_minutes = 0
for section in sections:
if current_meeting and current_minutes + section.estimated_reading_minutes > target_per_meeting * 1.4:
# Start new meeting if adding this section would overshoot too much
if len(meetings) < num_meetings - 1:
meetings.append({
"meeting_number": len(meetings) + 1,
"section_ids": current_meeting,
"total_minutes": current_minutes,
})
current_meeting = []
current_minutes = 0
current_meeting.append(section.id)
current_minutes += section.estimated_reading_minutes
# Add the last meeting
if current_meeting:
meetings.append({
"meeting_number": len(meetings) + 1,
"section_ids": current_meeting,
"total_minutes": current_minutes,
})
# If we have fewer than num_meetings, we could split the largest one
# For now, just return what we have
return meetings
def apply_schedule(group_book: GroupBook, num_meetings: int = 4) -> list[ReadingSchedule]:
"""Generate and persist a reading schedule."""
ReadingSchedule.objects.filter(group_book=group_book).delete()
recommendations = recommend_meetings(group_book, num_meetings)
today = date.today()
created: list[ReadingSchedule] = []
for rec in recommendations:
week_date = today + timedelta(weeks=rec["meeting_number"] - 1)
schedule = ReadingSchedule.objects.create(
group_book=group_book,
meeting_number=rec["meeting_number"],
week_date=week_date,
section_ids=rec["section_ids"],
)
created.append(schedule)
return created
+43
View File
@@ -0,0 +1,43 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from apps.groups.views import GroupBookViewSet, ReadingGroupViewSet
# ReadingGroup routes (standard ViewSet)
router = DefaultRouter()
router.register(r"", ReadingGroupViewSet, basename="group")
urlpatterns = [
path("", include(router.urls)),
# Nested GroupBook routes under a group
path(
"<int:group_pk>/books/",
GroupBookViewSet.as_view({"get": "list", "post": "create"}),
name="group-book-list",
),
path(
"<int:group_pk>/books/<int:pk>/",
GroupBookViewSet.as_view({"get": "retrieve", "delete": "destroy"}),
name="group-book-detail",
),
path(
"<int:group_pk>/books/<int:pk>/detect-sections/",
GroupBookViewSet.as_view({"post": "detect_sections_action"}),
name="group-book-detect-sections",
),
path(
"<int:group_pk>/books/<int:pk>/adjust-sections/",
GroupBookViewSet.as_view({"post": "adjust_sections"}),
name="group-book-adjust-sections",
),
path(
"<int:group_pk>/books/<int:pk>/schedule/",
GroupBookViewSet.as_view({"get": "schedule", "post": "schedule", "delete": "schedule"}),
name="group-book-schedule",
),
path(
"<int:group_pk>/books/<int:pk>/progress/",
GroupBookViewSet.as_view({"get": "progress", "patch": "progress"}),
name="group-book-progress",
),
]
+349
View File
@@ -0,0 +1,349 @@
from __future__ import annotations
import logging
from typing import Any
from django.db.models import QuerySet
from django.shortcuts import get_object_or_404
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
from apps.books.models import EBook
from apps.groups.models import (
GroupBook,
MemberProgress,
ReadingGroup,
ReadingGroupMembership,
ReadingSchedule,
Section,
)
from apps.groups.serializers import (
AddMemberSerializer,
GroupBookCreateSerializer,
GroupBookDetailSerializer,
GroupBookListSerializer,
MemberProgressSerializer,
ReadingGroupCreateSerializer,
ReadingGroupDetailSerializer,
ReadingGroupListSerializer,
ReadingScheduleSerializer,
SectionAdjustSerializer,
SectionSerializer,
)
from apps.groups.services.section_splitting import (
apply_schedule,
apply_sections,
detect_sections,
)
logger = logging.getLogger(__name__)
class IsGroupAdmin(permissions.BasePermission):
"""Only the group admin can modify group resources."""
def has_permission(self, request: Request, view: object) -> bool:
if not request.user.is_authenticated:
return False
if view.action in ("list", "retrieve"):
return True
group_id = view.kwargs.get("pk") or view.kwargs.get("group_pk")
if group_id:
return ReadingGroup.objects.filter(id=group_id, admin=request.user).exists()
return True
def has_object_permission(self, request: Request, view: object, obj: ReadingGroup) -> bool:
if request.method in permissions.SAFE_METHODS:
return True
return obj.admin == request.user
class IsGroupMember(permissions.BasePermission):
"""Only group members (including admin) can view group content."""
def has_permission(self, request: Request, view: object) -> bool:
if not request.user.is_authenticated:
return False
group_id = view.kwargs.get("pk") or view.kwargs.get("group_pk")
if group_id:
return ReadingGroupMembership.objects.filter(
group_id=group_id, user=request.user
).exists()
return True
class ReadingGroupViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticated, IsGroupAdmin]
queryset = ReadingGroup.objects.prefetch_related("memberships__user")
def get_serializer_class(self):
if self.action == "create":
return ReadingGroupCreateSerializer
if self.action == "retrieve":
return ReadingGroupDetailSerializer
return ReadingGroupListSerializer
def get_queryset(self) -> QuerySet[ReadingGroup]:
user = self.request.user
return ReadingGroup.objects.filter(
memberships__user=user
).prefetch_related("memberships__user").distinct()
def perform_create(self, serializer: ReadingGroupCreateSerializer) -> ReadingGroup:
group = serializer.save(admin=self.request.user)
ReadingGroupMembership.objects.create(
user=self.request.user, group=group, role=ReadingGroupMembership.Role.ADMIN
)
return group
@action(detail=True, methods=["post"], permission_classes=[IsGroupAdmin])
def add_member(self, request: Request, pk: int | None = None) -> Response:
group = self.get_object()
member_serializer = AddMemberSerializer(data=request.data)
member_serializer.is_valid(raise_exception=True)
user_id = member_serializer.validated_data["user_id"]
from django.conf import settings
User = settings.AUTH_USER_MODEL
new_member = get_object_or_404(User, id=user_id)
if ReadingGroupMembership.objects.filter(group=group, user=new_member).exists():
return Response(
{"detail": "User is already a member of this group."},
status=status.HTTP_409_CONFLICT,
)
membership = ReadingGroupMembership.objects.create(
user=new_member, group=group, role=ReadingGroupMembership.Role.MEMBER
)
from apps.groups.serializers import ReadingGroupMembershipSerializer
return Response(
ReadingGroupMembershipSerializer(membership).data,
status=status.HTTP_201_CREATED,
)
@action(detail=True, methods=["post"], permission_classes=[IsGroupAdmin])
def remove_member(self, request: Request, pk: int | None = None) -> Response:
group = self.get_object()
member_serializer = AddMemberSerializer(data=request.data)
member_serializer.is_valid(raise_exception=True)
user_id = member_serializer.validated_data["user_id"]
if user_id == group.admin_id:
return Response(
{"detail": "Cannot remove the group admin."},
status=status.HTTP_400_BAD_REQUEST,
)
deleted, _ = ReadingGroupMembership.objects.filter(
group=group, user_id=user_id
).delete()
if not deleted:
return Response(
{"detail": "User is not a member of this group."},
status=status.HTTP_404_NOT_FOUND,
)
return Response(status=status.HTTP_204_NO_CONTENT)
class GroupBookViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticated, IsGroupAdmin]
def get_serializer_class(self):
if self.action == "create":
return GroupBookCreateSerializer
if self.action == "retrieve":
return GroupBookDetailSerializer
return GroupBookListSerializer
def get_queryset(self) -> QuerySet[GroupBook]:
group_id = self.kwargs.get("group_pk")
return GroupBook.objects.filter(
group_id=group_id
).select_related("ebook", "ebook__user", "uploaded_by").prefetch_related(
"sections", "schedules"
)
def perform_create(self, serializer: GroupBookCreateSerializer) -> GroupBook:
group = get_object_or_404(ReadingGroup, id=self.kwargs["group_pk"])
ebook = get_object_or_404(EBook, id=serializer.validated_data["ebook_id"])
title = serializer.validated_data.get("title") or ebook.title
# Mark existing active books as replaced
GroupBook.objects.filter(group=group, status=GroupBook.Status.ACTIVE).update(
status=GroupBook.Status.REPLACED
)
group_book = GroupBook.objects.create(
group=group,
ebook=ebook,
uploaded_by=self.request.user,
title=title,
status=GroupBook.Status.ACTIVE,
)
# Auto-detect sections
try:
apply_sections(group_book)
except Exception:
logger.exception("Auto-section detection failed for group_book %s", group_book.id)
# Auto-generate schedule
try:
apply_schedule(group_book)
except Exception:
logger.exception("Schedule generation failed for group_book %s", group_book.id)
return group_book
@action(detail=True, methods=["post"], url_path="detect-sections")
def detect_sections_action(self, request: Request, group_pk: int | None = None, pk: int | None = None) -> Response:
group_book = self.get_object()
try:
sections = apply_sections(group_book)
except Exception as exc:
logger.exception("Section detection failed for group_book %s", group_book.id)
return Response({"error": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return Response(SectionSerializer(sections, many=True).data)
@action(detail=True, methods=["post"], url_path="adjust-sections")
def adjust_sections(self, request: Request, group_pk: int | None = None, pk: int | None = None) -> Response:
group_book = self.get_object()
adjust_serializer = SectionAdjustSerializer(data=request.data)
adjust_serializer.is_valid(raise_exception=True)
operation = adjust_serializer.validated_data["operation"]
section_ids: list[int] = adjust_serializer.validated_data["section_ids"]
if operation == "merge":
if len(section_ids) < 2:
return Response(
{"error": "At least 2 section IDs required for merge."},
status=status.HTTP_400_BAD_REQUEST,
)
sections = list(
Section.objects.filter(id__in=section_ids, group_book=group_book).order_by("order")
)
if len(sections) < 2:
return Response(
{"error": "Not enough valid sections found."},
status=status.HTTP_400_BAD_REQUEST,
)
merged_title = "".join(s.title for s in sections)
merged_minutes = sum(s.estimated_reading_minutes for s in sections)
merged_start = sections[0].start_chapter_index
merged_end = sections[-1].end_chapter_index
merged_order = sections[0].order
# Delete old sections
Section.objects.filter(id__in=section_ids, group_book=group_book).delete()
# Create merged section
merged = Section.objects.create(
group_book=group_book,
title=merged_title,
order=merged_order,
start_chapter_index=merged_start,
end_chapter_index=merged_end,
estimated_reading_minutes=merged_minutes,
)
# Reorder remaining sections
_renumber_sections(group_book)
return Response(SectionSerializer(merged).data)
if operation == "split":
if len(section_ids) != 1:
return Response(
{"error": "Exactly 1 section ID required for split."},
status=status.HTTP_400_BAD_REQUEST,
)
split_at = adjust_serializer.validated_data.get("split_at", 2)
section = get_object_or_404(Section, id=section_ids[0], group_book=group_book)
chapters = section.end_chapter_index - section.start_chapter_index
if chapters < split_at:
return Response(
{"error": f"Section only has {chapters} chapters, cannot split into {split_at}."},
status=status.HTTP_400_BAD_REQUEST,
)
# Delete original
orig_order = section.order
orig_title = section.title
section.delete()
# Create split sections
chunk_size = max(1, chapters // split_at)
new_sections: list[Section] = []
for i in range(split_at):
start_idx = section.start_chapter_index + i * chunk_size
end_idx = start_idx + chunk_size if i < split_at - 1 else section.end_chapter_index
minutes_each = max(1, section.estimated_reading_minutes // split_at)
new_sec = Section.objects.create(
group_book=group_book,
title=f"{orig_title} (Part {i + 1})",
order=orig_order + i,
start_chapter_index=start_idx,
end_chapter_index=end_idx,
estimated_reading_minutes=minutes_each,
)
new_sections.append(new_sec)
_renumber_sections(group_book)
return Response(SectionSerializer(new_sections, many=True).data)
return Response({"error": "Invalid operation."}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["get", "post", "delete"], url_path="schedule")
def schedule(self, request: Request, group_pk: int | None = None, pk: int | None = None) -> Response:
group_book = self.get_object()
if request.method == "GET":
schedules = ReadingSchedule.objects.filter(group_book=group_book).order_by("meeting_number")
return Response(ReadingScheduleSerializer(schedules, many=True).data)
if request.method == "POST":
ReadingSchedule.objects.filter(group_book=group_book).delete()
num_meetings = int(request.data.get("num_meetings", 4))
try:
schedules = apply_schedule(group_book, num_meetings=num_meetings)
except Exception as exc:
logger.exception("Schedule generation failed for group_book %s", group_book.id)
return Response({"error": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return Response(
ReadingScheduleSerializer(schedules, many=True).data,
status=status.HTTP_201_CREATED,
)
if request.method == "DELETE":
ReadingSchedule.objects.filter(group_book=group_book).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["get", "patch"], url_path="progress")
def progress(self, request: Request, group_pk: int | None = None, pk: int | None = None) -> Response:
group_book = self.get_object()
if request.method == "GET":
progress_records = MemberProgress.objects.filter(
group_book=group_book
).select_related("user", "current_section")
return Response(MemberProgressSerializer(progress_records, many=True).data)
# PATCH: update own progress
progress_obj, _created = MemberProgress.objects.get_or_create(
user=request.user, group_book=group_book,
)
serializer = MemberProgressSerializer(progress_obj, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
def _renumber_sections(group_book: GroupBook) -> None:
"""Re-number sections sequentially by their current order."""
sections = list(Section.objects.filter(group_book=group_book).order_by("order"))
for idx, section in enumerate(sections):
if section.order != idx + 1:
section.order = idx + 1
section.save(update_fields=["order"])
View File
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class ReaderConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
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
+7 -4
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
+3 -1
View File
@@ -39,7 +39,9 @@ INSTALLED_APPS = [
# Local apps # Local apps
"apps.users", "apps.users",
"apps.books", "apps.books",
"apps.groups",
"apps.annotations", "apps.annotations",
"apps.reader",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -144,7 +146,7 @@ USE_TZ = True
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
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 # Maximum upload size: 50MB
+8 -3
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 (
+8
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
@@ -5,5 +6,12 @@ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
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/groups/", include("apps.groups.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",
]
+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" },
]
+12 -13
View File
@@ -5,9 +5,9 @@ services:
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
environment: environment:
POSTGRES_DB: cloud_reader POSTGRES_DB: ${POSTGRES_DB:-cloud_reader}
POSTGRES_USER: postgres POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
ports: ports:
- "5432:5432" - "5432:5432"
healthcheck: healthcheck:
@@ -23,16 +23,16 @@ services:
ports: ports:
- "8000:8000" - "8000:8000"
environment: environment:
DJANGO_SECRET_KEY: "dev-secret-key-change-in-production" DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
DJANGO_DEBUG: "True" DJANGO_DEBUG: ${DJANGO_DEBUG:-True}
DB_NAME: cloud_reader DB_NAME: ${POSTGRES_DB:-cloud_reader}
DB_USER: postgres DB_USER: ${POSTGRES_USER:-postgres}
DB_PASSWORD: postgres DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
DB_HOST: db DB_HOST: ${DB_HOST:-db}
DB_PORT: "5432" DB_PORT: ${DB_PORT:-5432}
volumes: volumes:
- ./backend:/app - ./backend:/app
- book_media:/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:
@@ -46,7 +46,7 @@ services:
ports: ports:
- "5173:5173" - "5173:5173"
environment: environment:
VITE_API_URL: "http://localhost:8000" VITE_API_URL: ${VITE_API_URL:-http://localhost:8000}
volumes: volumes:
- ./frontend:/app - ./frontend:/app
- /app/node_modules - /app/node_modules
@@ -55,4 +55,3 @@ services:
volumes: volumes:
postgres_data: postgres_data:
book_media:
+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.
@@ -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.
+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>
+9 -6
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"
} }
} }
+32 -16
View File
@@ -1,25 +1,26 @@
import React, { lazy, Suspense, useState } from "react"; import React, { lazy, Suspense } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { BrowserRouter, Navigate, Route, Routes, useParams } from "react-router-dom";
import { AuthProvider, useAuth } from "./context/AuthContext"; 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 LibraryPage = lazy(() => import("./pages/Library").then((m) => ({ default: m.LibraryPage })));
const BookDetailPage = lazy(() => import("./pages/BookDetailPage").then((m) => ({ default: m.BookDetailPage }))); const BookDetailPage = lazy(() => import("./pages/BookDetailPage").then((m) => ({ default: m.BookDetailPage })));
const ReaderPage = lazy(() => import("./pages/Reader").then((m) => ({ default: m.ReaderPage })));
const AddBookPage = lazy(() => import("./pages/AddBook").then((m) => ({ default: m.AddBookPage }))); const AddBookPage = lazy(() => import("./pages/AddBook").then((m) => ({ default: m.AddBookPage })));
const SettingsPage = lazy(() => import("./pages/Settings").then((m) => ({ default: m.SettingsPage }))); const SettingsPage = lazy(() => import("./pages/Settings").then((m) => ({ default: m.SettingsPage })));
const BookmarksNotesPage = lazy(() => import("./components/annotations/BookmarksNotesPage").then((m) => ({ default: m.BookmarksNotesPage }))); const BookmarksNotesPage = lazy(() => import("./components/annotations/BookmarksNotesPage").then((m) => ({ default: m.BookmarksNotesPage })));
const ReadingPage = lazy(() => import("./pages/ReadingPage").then((m) => ({ default: m.default })));
const GroupsPage = lazy(() => import("./pages/GroupsPage").then((m) => ({ default: m.GroupsPage })));
const GroupDetailPage = lazy(() => import("./pages/GroupDetailPage").then((m) => ({ default: m.GroupDetailPage })));
const GroupBookPage = lazy(() => import("./pages/GroupBookPage").then((m) => ({ default: m.GroupBookPage })));
const AuthPage = lazy(() => const AuthPage = lazy(() => import("./pages/AuthPage"));
import("./pages/AuthPage").then((m) => ({
default: () => {
const [isLogin, setIsLogin] = useState(true);
return isLogin ? <m.LoginPage onToggle={() => setIsLogin(false)} /> : <m.RegisterPage onToggle={() => setIsLogin(true)} />;
},
})),
);
function LoadingFallback() { function LoadingFallback() {
return <div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", color: "#888", fontSize: 16 }}><p>Loading...</p></div>; 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 }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -29,6 +30,11 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
return <>{children}</>; return <>{children}</>;
} }
function ReaderRedirect() {
const { id } = useParams<{ id: string }>();
return <Navigate to={`/read/${id ?? ""}`} replace />;
}
function AppRoutes() { function AppRoutes() {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
return ( return (
@@ -37,9 +43,13 @@ function AppRoutes() {
<Route path="/auth" element={isAuthenticated ? <Navigate to="/" replace /> : <AuthPage />} /> <Route path="/auth" element={isAuthenticated ? <Navigate to="/" replace /> : <AuthPage />} />
<Route path="/" element={<ProtectedRoute><LibraryPage /></ProtectedRoute>} /> <Route path="/" element={<ProtectedRoute><LibraryPage /></ProtectedRoute>} />
<Route path="/books/:id" element={<ProtectedRoute><BookDetailPage /></ProtectedRoute>} /> <Route path="/books/:id" element={<ProtectedRoute><BookDetailPage /></ProtectedRoute>} />
<Route path="/reader/:id" element={<ProtectedRoute><ReaderPage /></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="/add" element={<ProtectedRoute><AddBookPage /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} /> <Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
<Route path="/groups" element={<ProtectedRoute><GroupsPage /></ProtectedRoute>} />
<Route path="/groups/:groupId" element={<ProtectedRoute><GroupDetailPage /></ProtectedRoute>} />
<Route path="/groups/:groupId/books/:bookId" element={<ProtectedRoute><GroupBookPage /></ProtectedRoute>} />
<Route path="/bookmarks-notes/:bookId?" element={<ProtectedRoute><BookmarksNotesPage /></ProtectedRoute>} /> <Route path="/bookmarks-notes/:bookId?" element={<ProtectedRoute><BookmarksNotesPage /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
@@ -50,9 +60,15 @@ function AppRoutes() {
export default function App() { export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<AuthProvider> <I18nProvider>
<AppRoutes /> <AuthProvider>
</AuthProvider> <ToastProvider>
<AnnotationsProvider>
<AppRoutes />
</AnnotationsProvider>
</ToastProvider>
</AuthProvider>
</I18nProvider>
</BrowserRouter> </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;
}
+39 -6
View File
@@ -13,8 +13,9 @@ import type {
export const booksApi = { export const booksApi = {
async getEBooks(): Promise<EBookListItem[]> { async getEBooks(): Promise<EBookListItem[]> {
const { data } = await api.get<EBookListItem[]>("/books/ebooks/"); const { data } = await api.get<{ count: number; results: EBookListItem[] } | EBookListItem[]>("/books/ebooks/");
return data; if (Array.isArray(data)) return data;
return data.results ?? [];
}, },
async getEBook(id: number): Promise<EBookDetail> { async getEBook(id: number): Promise<EBookDetail> {
@@ -22,26 +23,53 @@ export const booksApi = {
return data; return data;
}, },
async searchBooks(params: BookSearchParams = {}): Promise<{ count: number; results: BookListItem[] }> { async getEbookFile(id: number): Promise<Blob> {
const { data } = await api.get<{ count: number; results: BookListItem[] }>("/books/", { params }); const { data } = await api.get<Blob>(`/books/ebooks/${id}/file/`, {
responseType: "blob",
});
return data; 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> { async getBook(id: number): Promise<BookDetail> {
const { data } = await api.get<BookDetail>(`/books/${id}/`); const { data } = await api.get<BookDetail>(`/books/${id}/`);
return data; return data;
}, },
async getGenres(): Promise<string[]> { async getGenres(): Promise<string[]> {
const { data } = await api.get<string[]>("/books/genres/"); const { data } = await api.get<string[]>("/books/ebooks/genres/");
return data; return data;
}, },
async getAuthors(): Promise<string[]> { async getAuthors(): Promise<string[]> {
const { data } = await api.get<string[]>("/books/authors/"); const { data } = await api.get<string[]>("/books/ebooks/authors/");
return data; 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( async uploadEBook(
file: File, file: File,
title: string, title: string,
@@ -64,6 +92,11 @@ export const booksApi = {
await api.delete(`/books/ebooks/${id}/`); 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 }> { async processEBook(id: number): Promise<{ status: string }> {
const { data } = await api.post<{ status: string }>(`/books/ebooks/${id}/process/`); const { data } = await api.post<{ status: string }>(`/books/ebooks/${id}/process/`);
return data; 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;
+40
View File
@@ -0,0 +1,40 @@
import axios from "axios";
function messagesFromValue(value: unknown): string[] {
if (value == null) return [];
if (typeof value === "string") return [value];
if (Array.isArray(value)) return value.flatMap(messagesFromValue);
if (typeof value === "object") {
const record = value as Record<string, unknown>;
if ("detail" in record) {
const fromDetail = messagesFromValue(record.detail);
if (fromDetail.length > 0) return fromDetail;
}
const messages: string[] = [];
for (const [key, nested] of Object.entries(record)) {
if (key === "detail") continue;
for (const part of messagesFromValue(nested)) {
messages.push(key === "non_field_errors" ? part : `${key}: ${part}`);
}
}
return messages;
}
return [];
}
/** Extract human-readable message(s) from a Django REST Framework / axios error response. */
export function getApiErrorMessage(err: unknown, fallback = "Something went wrong"): string {
if (axios.isAxiosError(err)) {
const data = err.response?.data;
if (data !== undefined) {
const messages = messagesFromValue(data);
if (messages.length > 0) return messages.join(". ");
}
if (err.response?.status && err.message.startsWith("Request failed")) {
return fallback;
}
return err.message || fallback;
}
if (err instanceof Error) return err.message;
return fallback;
}
+151
View File
@@ -0,0 +1,151 @@
import api from "./client";
import type {
AddMemberPayload,
AdjustSectionsPayload,
CreateGroupBookPayload,
CreateGroupPayload,
GroupBook,
GroupBookDetail,
MemberProgress,
ReadingGroup,
ReadingGroupDetail,
ReadingSchedule,
Section,
} from "../types/group";
export const groupsApi = {
// ---- Reading Groups ----
async listGroups(): Promise<ReadingGroup[]> {
const { data } = await api.get<{ count: number; results: ReadingGroup[] } | ReadingGroup[]>(
"/groups/"
);
if (Array.isArray(data)) return data;
return data.results ?? [];
},
async getGroup(id: number): Promise<ReadingGroupDetail> {
const { data } = await api.get<ReadingGroupDetail>(`/groups/${id}/`);
return data;
},
async createGroup(payload: CreateGroupPayload): Promise<ReadingGroup> {
const { data } = await api.post<ReadingGroup>("/groups/", payload);
return data;
},
async deleteGroup(id: number): Promise<void> {
await api.delete(`/groups/${id}/`);
},
async addMember(groupId: number, payload: AddMemberPayload): Promise<void> {
await api.post(`/groups/${groupId}/add_member/`, payload);
},
async removeMember(groupId: number, payload: AddMemberPayload): Promise<void> {
await api.post(`/groups/${groupId}/remove_member/`, payload);
},
// ---- Group Books ----
async listGroupBooks(groupId: number): Promise<GroupBook[]> {
const { data } = await api.get<GroupBook[]>(`/groups/${groupId}/books/`);
return Array.isArray(data) ? data : (data as { results: GroupBook[] }).results ?? [];
},
async getGroupBook(groupId: number, bookId: number): Promise<GroupBookDetail> {
const { data } = await api.get<GroupBookDetail>(`/groups/${groupId}/books/${bookId}/`);
return data;
},
async createGroupBook(
groupId: number,
payload: CreateGroupBookPayload
): Promise<GroupBookDetail> {
const { data } = await api.post<GroupBookDetail>(
`/groups/${groupId}/books/`,
payload
);
return data;
},
async deleteGroupBook(groupId: number, bookId: number): Promise<void> {
await api.delete(`/groups/${groupId}/books/${bookId}/`);
},
// ---- Sections ----
async detectSections(
groupId: number,
bookId: number
): Promise<Section[]> {
const { data } = await api.post<Section[]>(
`/groups/${groupId}/books/${bookId}/detect-sections/`
);
return data;
},
async adjustSections(
groupId: number,
bookId: number,
payload: AdjustSectionsPayload
): Promise<Section | Section[]> {
const { data } = await api.post<Section | Section[]>(
`/groups/${groupId}/books/${bookId}/adjust-sections/`,
payload
);
return data;
},
// ---- Schedule ----
async getSchedule(
groupId: number,
bookId: number
): Promise<ReadingSchedule[]> {
const { data } = await api.get<ReadingSchedule[]>(
`/groups/${groupId}/books/${bookId}/schedule/`
);
return data;
},
async generateSchedule(
groupId: number,
bookId: number,
numMeetings: number = 4
): Promise<ReadingSchedule[]> {
const { data } = await api.post<ReadingSchedule[]>(
`/groups/${groupId}/books/${bookId}/schedule/`,
{ num_meetings: numMeetings }
);
return data;
},
async deleteSchedule(groupId: number, bookId: number): Promise<void> {
await api.delete(`/groups/${groupId}/books/${bookId}/schedule/`);
},
// ---- Progress ----
async getProgress(
groupId: number,
bookId: number
): Promise<MemberProgress[]> {
const { data } = await api.get<MemberProgress[]>(
`/groups/${groupId}/books/${bookId}/progress/`
);
return data;
},
async updateProgress(
groupId: number,
bookId: number,
payload: { current_section?: number; completed_sections?: number[] }
): Promise<MemberProgress> {
const { data } = await api.patch<MemberProgress>(
`/groups/${groupId}/books/${bookId}/progress/`,
payload
);
return data;
},
};
+14
View File
@@ -0,0 +1,14 @@
import { booksApi } from "@/api/books";
import { getReadingProgress } from "@/api/reader";
import type { ReadingProgress } from "@/types/reader";
export async function loadEbookWithProgress(bookId: number): Promise<{
progressData: ReadingProgress | null;
blob: Blob;
}> {
const [progressData, blob] = await Promise.all([
getReadingProgress(bookId).catch(() => null),
booksApi.getEbookFile(bookId),
]);
return { progressData, blob };
}
+70
View File
@@ -0,0 +1,70 @@
/**
* API client for the reader module reading settings and progress.
* Uses the shared axios client so JWT auth is attached automatically.
*/
import api from "./client";
import type { ReadingProgress, ReadingSettings } from "../types/reader";
export async function getReadingSettings(): Promise<ReadingSettings> {
const { data } = await api.get<ReadingSettings>("/reader/settings/");
return data;
}
export async function updateReadingSettings(
settings: Partial<ReadingSettings>,
): Promise<ReadingSettings> {
const { data } = await api.patch<ReadingSettings>("/reader/settings/", settings);
return data;
}
export async function getReadingProgress(
bookId: number,
): Promise<ReadingProgress> {
const { data } = await api.get<{
current_position: number;
last_page: number;
epub_location?: string;
updated_at?: string;
}>(`/books/ebooks/${bookId}/progress/`);
return {
id: bookId,
book: bookId,
current_chapter: data.last_page || 1,
current_position: data.current_position ?? 0,
percentage: data.current_position ?? 0,
epub_location: data.epub_location ?? "",
updated_at: data.updated_at ?? "",
};
}
export async function updateReadingProgress(
bookId: number,
progress: Partial<ReadingProgress>,
): Promise<ReadingProgress> {
const body: Record<string, string | number> = {};
if (progress.percentage !== undefined || progress.current_position !== undefined) {
body.current_position = progress.percentage ?? progress.current_position ?? 0;
}
if (progress.current_chapter !== undefined) {
body.last_page = progress.current_chapter;
}
if (progress.epub_location !== undefined) {
body.epub_location = progress.epub_location;
}
const { data } = await api.patch<{
current_position: number;
last_page: number;
epub_location?: string;
updated_at?: string;
}>(`/books/ebooks/${bookId}/progress/`, body);
return {
id: bookId,
book: bookId,
current_chapter: data.last_page || 1,
current_position: data.current_position ?? 0,
percentage: data.current_position ?? 0,
epub_location: data.epub_location ?? "",
updated_at: data.updated_at ?? "",
};
}
@@ -0,0 +1,51 @@
.menu {
position: fixed;
z-index: 9999;
min-width: 180px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
padding: 4px 0;
overflow: hidden;
}
.item {
display: block;
width: 100%;
padding: 10px 16px;
border: none;
background: transparent;
text-align: left;
font-size: 14px;
color: #374151;
cursor: pointer;
transition: background 0.1s;
}
.item:hover:not(:disabled) {
background: #f3f4f6;
}
.item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.itemDanger {
color: #dc2626;
}
.itemDanger:hover:not(:disabled) {
background: #fef2f2;
}
.itemLoading {
color: #6b7280;
}
.separator {
height: 1px;
background: #e5e7eb;
margin: 4px 0;
}
+163
View File
@@ -0,0 +1,163 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18n-lite";
import { booksApi } from "../api/books";
import { getApiErrorMessage } from "../api/errors";
import { useToast } from "../hooks/useToast";
import type { BookListItem } from "../types/book";
import type { SupportedLanguage } from "../locales";
import { subjectsFromMetadata } from "../utils/ebookLibrary";
import styles from "./BookContextMenu.module.css";
type LibraryBook = BookListItem & {
format: string;
progressPercent: number | null;
subjects: string[];
};
interface BookContextMenuProps {
book: LibraryBook;
x: number;
y: number;
onClose: () => void;
onBookUpdated: (book: LibraryBook) => void;
onBookRemoved: (id: number) => void;
}
type ActionKind = "sync" | "remove" | null;
function clampPosition(x: number, y: number, width: number, height: number) {
const padding = 8;
const maxX = window.innerWidth - width - padding;
const maxY = window.innerHeight - height - padding;
return {
left: Math.max(padding, Math.min(x, maxX)),
top: Math.max(padding, Math.min(y, maxY)),
};
}
export function BookContextMenu({
book,
x,
y,
onClose,
onBookUpdated,
onBookRemoved,
}: BookContextMenuProps) {
const { t, language } = useTranslation();
const locale = language as SupportedLanguage;
const { showToast } = useToast();
const menuRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ left: x, top: y });
const [activeAction, setActiveAction] = useState<ActionKind>(null);
useLayoutEffect(() => {
const el = menuRef.current;
if (!el) return;
setPosition(clampPosition(x, y, el.offsetWidth, el.offsetHeight));
}, [x, y]);
useEffect(() => {
const handlePointerDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
const handleScroll = () => onClose();
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("scroll", handleScroll, true);
};
}, [onClose]);
const handleSyncMetadata = async () => {
setActiveAction("sync");
try {
const updated = await booksApi.enrichEBookMetadata(book.id);
const subjects = subjectsFromMetadata(updated.metadata, locale);
onBookUpdated({
...book,
id: updated.id,
title: updated.title,
author: updated.author,
subjects,
genre: subjects[0] ?? "",
format: updated.format,
cover_image: updated.cover_image,
progressPercent: book.progressPercent,
});
const status = updated.metadata?.match_status;
if (status === "matched") {
showToast({ message: t("toast.metadataSynced", { title: updated.title }), variant: "success" });
} else if (status === "not_found") {
showToast({ message: t("toast.noMatch"), variant: "warning" });
} else {
showToast({ message: t("toast.refreshDone"), variant: "success" });
}
} catch (err) {
showToast({ message: getApiErrorMessage(err, t("common.unknownError")), variant: "error" });
} finally {
setActiveAction(null);
onClose();
}
};
const handleRemove = async () => {
const confirmed = window.confirm(t("contextMenu.confirmRemove", { title: book.title }));
if (!confirmed) return;
setActiveAction("remove");
try {
await booksApi.deleteEBook(book.id);
onBookRemoved(book.id);
showToast({ message: t("toast.removed", { title: book.title }), variant: "success" });
} catch (err) {
showToast({ message: getApiErrorMessage(err, t("common.unknownError")), variant: "error" });
} finally {
setActiveAction(null);
onClose();
}
};
const busy = activeAction !== null;
return (
<div
ref={menuRef}
className={styles.menu}
style={{ left: position.left, top: position.top }}
role="menu"
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
>
<button
type="button"
className={`${styles.item} ${activeAction === "sync" ? styles.itemLoading : ""}`}
role="menuitem"
disabled={busy}
onClick={() => void handleSyncMetadata()}
>
{activeAction === "sync" ? t("contextMenu.syncingMetadata") : t("contextMenu.syncMetadata")}
</button>
<div className={styles.separator} role="separator" />
<button
type="button"
className={`${styles.item} ${styles.itemDanger} ${activeAction === "remove" ? styles.itemLoading : ""}`}
role="menuitem"
disabled={busy}
onClick={() => void handleRemove()}
>
{activeAction === "remove" ? t("contextMenu.removing") : t("contextMenu.remove")}
</button>
</div>
);
}
@@ -0,0 +1,50 @@
.container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 10000;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
max-width: min(360px, calc(100vw - 32px));
}
.toast {
pointer-events: auto;
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
line-height: 1.4;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
animation: slideIn 0.2s ease-out;
}
.success {
background: #ecfdf5;
color: #065f46;
border: 1px solid #a7f3d0;
}
.error {
background: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
.warning {
background: #fffbeb;
color: #92400e;
border: 1px solid #fde68a;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@@ -0,0 +1,20 @@
import type { ToastItem } from "../hooks/useToast";
import styles from "./ToastContainer.module.css";
interface ToastContainerProps {
toasts: ToastItem[];
}
export function ToastContainer({ toasts }: ToastContainerProps) {
if (toasts.length === 0) return null;
return (
<div className={styles.container} role="status" aria-live="polite">
{toasts.map((toast) => (
<div key={toast.id} className={`${styles.toast} ${styles[toast.variant]}`}>
{toast.message}
</div>
))}
</div>
);
}
@@ -1,120 +0,0 @@
import React, { useState } from "react";
import { useAnnotations } from "@/context/AnnotationsContext";
interface AddAnnotationFormProps {
bookId: string;
page: number;
locationText?: string;
onClose?: () => void;
}
export function AddAnnotationForm({
bookId,
page,
locationText,
onClose,
}: AddAnnotationFormProps): React.ReactElement {
const { addBookmark, addNote } = useAnnotations();
const [mode, setMode] = useState<"bookmark" | "note" | null>(null);
const [noteContent, setNoteContent] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (): Promise<void> => {
setSubmitting(true);
setError(null);
try {
if (mode === "bookmark") {
await addBookmark({ book: bookId, page, location_text: locationText });
} else if (mode === "note") {
if (!noteContent.trim()) {
setError("Note content cannot be empty.");
setSubmitting(false);
return;
}
await addNote({
book: bookId,
page,
location_text: locationText,
content: noteContent.trim(),
});
}
onClose?.();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to save annotation."
);
} finally {
setSubmitting(false);
}
};
return (
<div className="add-annotation-overlay">
<div className="add-annotation-modal">
<button className="modal-close" onClick={onClose} aria-label="Close">
&times;
</button>
<h3>Add to Page {page}</h3>
{locationText && (
<blockquote className="annotation-quote">
&ldquo;{locationText}&rdquo;
</blockquote>
)}
{!mode ? (
<div className="mode-selector">
<button
className="btn btn-block"
onClick={() => setMode("bookmark")}
>
Add Bookmark
</button>
<button
className="btn btn-block btn-secondary"
onClick={() => setMode("note")}
>
Add Note
</button>
</div>
) : (
<div className="annotation-form">
{mode === "note" && (
<div className="form-group">
<label htmlFor="note-content">Note:</label>
<textarea
id="note-content"
className="form-textarea"
value={noteContent}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setNoteContent(e.target.value)
}
rows={5}
placeholder="Write your note here..."
autoFocus
/>
</div>
)}
{error && <div className="form-error">{error}</div>}
<div className="form-actions">
<button
className="btn"
onClick={handleSubmit}
disabled={submitting}
>
{submitting ? "Saving..." : "Save"}
</button>
<button
className="btn btn-secondary"
onClick={() => setMode(null)}
disabled={submitting}
>
Back
</button>
</div>
</div>
)}
</div>
</div>
);
}
@@ -1,109 +0,0 @@
import React from "react";
import { useAnnotations } from "@/context/AnnotationsContext";
import type { AnnotationEntry } from "@/types";
interface AnnotationsDashboardProps {
bookId?: string;
onNavigateToPage?: (bookId: string, page: number) => void;
}
export function AnnotationsDashboard({
bookId,
onNavigateToPage,
}: AnnotationsDashboardProps): React.ReactElement {
const {
mergedAnnotations,
loadBookmarks,
loadNotes,
removeBookmark,
removeNote,
state,
} = useAnnotations();
React.useEffect(() => {
loadBookmarks(bookId);
loadNotes(bookId);
}, [loadBookmarks, loadNotes, bookId]);
const handleDelete = async (entry: AnnotationEntry): Promise<void> => {
if (entry.kind === "bookmark") {
await removeBookmark(entry.id);
} else {
await removeNote(entry.id);
}
};
if (state.bookmarksLoading || state.notesLoading) {
return <div className="annotations-loading">Loading annotations...</div>;
}
if (mergedAnnotations.length === 0) {
return (
<div className="annotations-empty">
No bookmarks or notes yet.
</div>
);
}
return (
<div className="annotations-dashboard">
<div className="annotations-summary">
<span className="summary-count">
{state.bookmarks.length} bookmarks
</span>
<span className="summary-separator">&middot;</span>
<span className="summary-count">
{state.notes.length} notes
</span>
</div>
<div className="annotations-list">
{mergedAnnotations.map((entry: AnnotationEntry) => (
<div key={`${entry.kind}-${entry.id}`} className="annotation-card">
<div className="annotation-card-header">
<span
className={`annotation-kind-badge ${
entry.kind === "bookmark" ? "bookmark-badge" : "note-badge"
}`}
>
{entry.kind === "bookmark" ? "Bookmark" : "Note"}
</span>
<span className="annotation-book-title">
{entry.book_title}
</span>
<span className="annotation-page">p.{entry.page}</span>
<span className="annotation-date">
{new Date(entry.created_at).toLocaleDateString()}
</span>
</div>
{entry.location_text && (
<blockquote className="annotation-quote">
&ldquo;{entry.location_text}&rdquo;
</blockquote>
)}
{entry.kind === "note" && entry.content && (
<p className="note-content-text">{entry.content}</p>
)}
<div className="annotation-actions">
{onNavigateToPage && (
<button
className="btn btn-sm"
onClick={() =>
onNavigateToPage(entry.book_id, entry.page)
}
>
Go to page
</button>
)}
<button
className="btn btn-sm btn-danger"
onClick={() => handleDelete(entry)}
>
Delete
</button>
</div>
</div>
))}
</div>
</div>
);
}
@@ -1,87 +0,0 @@
import React, { useState } from "react";
import { useAnnotations } from "@/context/AnnotationsContext";
import type { Bookmark } from "@/types";
interface BookmarkListProps {
bookId?: string;
onNavigateToPage?: (bookId: string, page: number) => void;
}
export function BookmarkList({
bookId,
onNavigateToPage,
}: BookmarkListProps): React.ReactElement {
const { state, loadBookmarks, removeBookmark } = useAnnotations();
const [deletingId, setDeletingId] = useState<string | null>(null);
React.useEffect(() => {
loadBookmarks(bookId);
}, [loadBookmarks, bookId]);
const handleDelete = async (id: string): Promise<void> => {
setDeletingId(id);
try {
await removeBookmark(id);
} catch {
// error handled by context
} finally {
setDeletingId(null);
}
};
if (state.bookmarksLoading) {
return <div className="annotations-loading">Loading bookmarks...</div>;
}
if (state.bookmarks.length === 0) {
return (
<div className="annotations-empty">
No bookmarks yet. Select a passage and add a bookmark while reading.
</div>
);
}
return (
<div className="annotations-list">
{state.bookmarks.map((bookmark: Bookmark) => (
<div key={bookmark.id} className="annotation-card">
<div className="annotation-card-header">
<span className="annotation-kind-badge bookmark-badge">
Bookmark
</span>
<span className="annotation-page">
Page {bookmark.page}
</span>
<span className="annotation-date">
{new Date(bookmark.created_at).toLocaleDateString()}
</span>
</div>
{bookmark.location_text && (
<blockquote className="annotation-quote">
&ldquo;{bookmark.location_text}&rdquo;
</blockquote>
)}
<div className="annotation-actions">
{onNavigateToPage && (
<button
className="btn btn-sm"
onClick={() =>
onNavigateToPage(bookmark.book, bookmark.page)
}
>
Go to page
</button>
)}
<button
className="btn btn-sm btn-danger"
onClick={() => handleDelete(bookmark.id)}
disabled={deletingId === bookmark.id}
>
{deletingId === bookmark.id ? "Deleting..." : "Delete"}
</button>
</div>
</div>
))}
</div>
);
}
@@ -1,21 +1,36 @@
import React from "react"; import React from "react";
import { AnnotationsDashboard } from "@/components/annotations"; import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18n-lite";
import { MarkerThreadsView } from "./MarkerThreadsView";
interface BookmarksNotesPageProps { export function BookmarksNotesPage(): React.ReactElement {
bookId?: string; const { t } = useTranslation();
} const navigate = useNavigate();
const { bookId } = useParams<{ bookId?: string }>();
export function BookmarksNotesPage({
bookId,
}: BookmarksNotesPageProps): React.ReactElement {
return ( return (
<div className="page"> <div className="page bookmarks-notes-page" style={{ maxWidth: 720, margin: "0 auto", padding: 16 }}>
<h2>Bookmarks & Notes</h2> <header style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 24 }}>
<AnnotationsDashboard <button
bookId={bookId} type="button"
onNavigateToPage={(bookId, page) => { onClick={() => navigate("/")}
// Navigate to the book reader page at the specific page style={{
window.location.href = `/books/${bookId}?page=${page}`; padding: "8px 16px",
borderRadius: 6,
border: "1px solid #ddd",
background: "#fff",
cursor: "pointer",
fontSize: 14,
}}
>
{t("common.back")}
</button>
<h2 style={{ margin: 0, fontSize: 22, fontWeight: 700 }}>{t("annotations.title")}</h2>
</header>
<MarkerThreadsView
ebookIdFilter={bookId}
onGoToPassage={(ebookId, epubCfi) => {
navigate(`/read/${ebookId}`, { state: { epubLocation: epubCfi } });
}} }}
/> />
</div> </div>

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