Compare commits

...
Author SHA1 Message Date
Marko 4a7133465f feat: US #32 Section-based Reading Recommendations
Backend:
- Add ReadingSchedule and MeetingSection models for ebook reading schedules
- Implement recommendation algorithm: greedy partition minimizing per-meeting time variance
- Algorithm respects chapter boundaries, estimates time based on chapter weight
- Add serializers for schedules (list/detail/generate/confirm/update)
- Add ScheduleViewSet with endpoints: list, create (generate), retrieve, regenerate, confirm, update_meeting, reorder_meetings
- Add URL routes for /api/books/schedules/
- Create migration 0004 for new models

Frontend:
- Add types: MeetingSection, ReadingSchedule, GenerateRecommendationsRequest
- Add schedulesApi with methods: getSchedules, generateRecommendations, confirmSchedule, updateMeeting, reorderMeetings, deleteSchedule
- Build RecommendationPage with 4-column meeting layout, drag-and-drop chapter reassignment, generate/regenerate/confirm flow
- Add 'Schedule Reading' menu item to BookContextMenu
- Add route: /books/:ebookId/schedule
2026-06-20 19:31:39 +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
218 changed files with 55338 additions and 1840 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
```
+38 -2
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.
+9 -1
View File
@@ -5,4 +5,12 @@ DB_NAME=cloud_reader
DB_USER=postgres DB_USER=postgres
DB_PASSWORD=postgres DB_PASSWORD=postgres
DB_HOST=localhost DB_HOST=localhost
DB_PORT=5432 DB_PORT=5432
# Open Library metadata enrichment (optional)
OPENLIBRARY_ENABLED=true
OPENLIBRARY_PREFERRED_LANG=es
OPENLIBRARY_FALLBACK_LANG=en
OPENLIBRARY_TIMEOUT_SECONDS=10
OPENLIBRARY_CONNECT_TIMEOUT_SECONDS=15
OPENLIBRARY_USER_AGENT=CloudReader/1.0
+1
View File
@@ -0,0 +1 @@
3.12
+11 -3
View File
@@ -5,9 +5,17 @@ from apps.annotations.models import Bookmark, Note
@admin.register(Bookmark) @admin.register(Bookmark)
class BookmarkAdmin(admin.ModelAdmin): class BookmarkAdmin(admin.ModelAdmin):
list_display = ("user", "book", "page", "created_at") list_display = (
list_select_related = ("user", "book") "user",
search_fields = ("user__email", "book__title", "location_text") "ebook",
"chapter_index",
"chapter_title",
"page",
"highlight_color",
"created_at",
)
list_select_related = ("user", "ebook")
search_fields = ("user__email", "ebook__title", "location_text", "content")
list_filter = ("created_at",) list_filter = ("created_at",)
@@ -0,0 +1,57 @@
# Generated by Django 5.1.7 on 2026-06-03 22:27
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('books', '0002_ebook_file_size_ebook_format_ebook_metadata_json_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Note',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('page', models.PositiveIntegerField()),
('location_text', models.TextField(blank=True, default='', help_text='The selected passage text this note refers to')),
('content', models.TextField(help_text='The note body content')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='books.book')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Note',
'verbose_name_plural': 'Notes',
'db_table': 'annotations_note',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Bookmark',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('page', models.PositiveIntegerField()),
('location_text', models.TextField(blank=True, default='', help_text='The selected passage text at this location')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to='books.book')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Bookmark',
'verbose_name_plural': 'Bookmarks',
'db_table': 'annotations_bookmark',
'ordering': ['-created_at'],
'constraints': [models.UniqueConstraint(fields=('user', 'book', 'page'), name='uq_bookmark_user_book_page')],
},
),
]
@@ -0,0 +1,93 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
def clear_legacy_bookmarks(apps, schema_editor):
Bookmark = apps.get_model("annotations", "Bookmark")
Bookmark.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
("books", "0003_readingprogress_epub_location"),
("annotations", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RunPython(clear_legacy_bookmarks, migrations.RunPython.noop),
migrations.RemoveConstraint(
model_name="bookmark",
name="uq_bookmark_user_book_page",
),
migrations.RemoveField(
model_name="bookmark",
name="book",
),
migrations.AddField(
model_name="bookmark",
name="ebook",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="bookmarks",
to="books.ebook",
null=True,
),
),
migrations.AddField(
model_name="bookmark",
name="epub_cfi",
field=models.CharField(db_index=True, default="", max_length=2048),
preserve_default=False,
),
migrations.AddField(
model_name="bookmark",
name="chapter_index",
field=models.PositiveIntegerField(db_index=True, default=0),
),
migrations.AddField(
model_name="bookmark",
name="chapter_title",
field=models.CharField(blank=True, default="", max_length=512),
),
migrations.AddField(
model_name="bookmark",
name="content",
field=models.TextField(blank=True, default=""),
),
migrations.AlterField(
model_name="bookmark",
name="page",
field=models.PositiveIntegerField(
default=1,
help_text="Legacy/display page; derived from chapter_index + 1",
),
),
migrations.AlterModelOptions(
name="bookmark",
options={
"ordering": ["chapter_index", "epub_cfi"],
"verbose_name": "Bookmark",
"verbose_name_plural": "Bookmarks",
},
),
# ebook was added nullable for SQLite; enforce NOT NULL via AlterField
migrations.AlterField(
model_name="bookmark",
name="ebook",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="bookmarks",
to="books.ebook",
),
),
migrations.AddConstraint(
model_name="bookmark",
constraint=models.UniqueConstraint(
fields=("user", "ebook", "epub_cfi"),
name="uq_bookmark_user_ebook_cfi",
),
),
]
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("annotations", "0002_bookmark_ebook_epub_fields"),
]
operations = [
migrations.AddField(
model_name="bookmark",
name="highlight_color",
field=models.CharField(default="#fde047", max_length=7),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.1.7 on 2026-06-04 03:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('annotations', '0003_bookmark_highlight_color'),
]
operations = [
migrations.AlterField(
model_name='bookmark',
name='content',
field=models.TextField(blank=True, default='', help_text='Optional user thought; empty means bookmark-only'),
),
migrations.AlterField(
model_name='bookmark',
name='highlight_color',
field=models.CharField(default='#fde047', help_text='Hex color for in-book passage highlight (e.g. #fde047)', max_length=7),
),
]
+26 -10
View File
@@ -5,7 +5,7 @@ from django.db import models
class Bookmark(models.Model): class Bookmark(models.Model):
"""A saved location in a book that the user can return to.""" """A saved passage anchor in an uploaded ebook (EPUB CFI + optional thought)."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey( user = models.ForeignKey(
@@ -14,18 +14,34 @@ class Bookmark(models.Model):
related_name="bookmarks", related_name="bookmarks",
db_index=True, db_index=True,
) )
book = models.ForeignKey( ebook = models.ForeignKey(
"books.Book", "books.EBook",
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="bookmarks", related_name="bookmarks",
db_index=True, db_index=True,
) )
page = models.PositiveIntegerField() epub_cfi = models.CharField(max_length=2048, db_index=True)
chapter_index = models.PositiveIntegerField(default=0, db_index=True)
chapter_title = models.CharField(max_length=512, blank=True, default="")
page = models.PositiveIntegerField(
default=1,
help_text="Legacy/display page; derived from chapter_index + 1",
)
location_text = models.TextField( location_text = models.TextField(
blank=True, blank=True,
default="", default="",
help_text="The selected passage text at this location", help_text="The selected passage text at this location",
) )
content = models.TextField(
blank=True,
default="",
help_text="Optional user thought; empty means bookmark-only",
)
highlight_color = models.CharField(
max_length=7,
default="#fde047",
help_text="Hex color for in-book passage highlight (e.g. #fde047)",
)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -33,20 +49,20 @@ class Bookmark(models.Model):
db_table = "annotations_bookmark" db_table = "annotations_bookmark"
verbose_name = "Bookmark" verbose_name = "Bookmark"
verbose_name_plural = "Bookmarks" verbose_name_plural = "Bookmarks"
ordering = ["-created_at"] ordering = ["chapter_index", "epub_cfi"]
constraints = [ constraints = [
models.UniqueConstraint( models.UniqueConstraint(
fields=["user", "book", "page"], fields=["user", "ebook", "epub_cfi"],
name="uq_bookmark_user_book_page", name="uq_bookmark_user_ebook_cfi",
) )
] ]
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.user} @ {self.book} p.{self.page}" return f"{self.user} @ {self.ebook} ch.{self.chapter_index}"
class Note(models.Model): class Note(models.Model):
"""A user-written note attached to a specific location in a book.""" """Legacy note model; new UX uses Bookmark.content instead."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey( user = models.ForeignKey(
@@ -81,4 +97,4 @@ class Note(models.Model):
def __str__(self) -> str: def __str__(self) -> str:
preview = self.content[:50] preview = self.content[:50]
return f"{self.user} @ {self.book} p.{self.page}: {preview}" return f"{self.user} @ {self.book} p.{self.page}: {preview}"
+48 -32
View File
@@ -1,62 +1,86 @@
import re
from rest_framework import serializers from rest_framework import serializers
from apps.annotations.models import Bookmark, Note from apps.annotations.models import Bookmark, Note
_HEX_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
def validate_highlight_color(value: str) -> str:
stripped = (value or "").strip()
if not _HEX_COLOR_RE.match(stripped):
raise serializers.ValidationError("highlight_color must be a hex color like #fde047.")
return stripped.lower()
class BookmarkSerializer(serializers.ModelSerializer): class BookmarkSerializer(serializers.ModelSerializer):
"""Serialize Bookmark data with full details.""" ebook_title = serializers.CharField(source="ebook.title", read_only=True)
book_title = serializers.CharField(source="book.title", read_only=True)
class Meta: class Meta:
model = Bookmark model = Bookmark
fields = [ fields = [
"id", "id",
"book", "ebook",
"book_title", "ebook_title",
"epub_cfi",
"chapter_index",
"chapter_title",
"page", "page",
"location_text", "location_text",
"content",
"highlight_color",
"created_at", "created_at",
"updated_at", "updated_at",
] ]
read_only_fields = ["id", "created_at", "updated_at", "book_title"] read_only_fields = ["id", "created_at", "updated_at", "ebook_title", "page"]
def validate_page(self, value: int) -> int:
if value < 1:
raise serializers.ValidationError("Page must be a positive integer.")
return value
class BookmarkCreateSerializer(serializers.ModelSerializer): class BookmarkCreateSerializer(serializers.ModelSerializer):
"""Serializer used for creating bookmarks. Sets user from request context."""
class Meta: class Meta:
model = Bookmark model = Bookmark
fields = ["book", "page", "location_text"] fields = [
"ebook",
"epub_cfi",
"chapter_index",
"chapter_title",
"location_text",
"content",
"highlight_color",
]
def validate_page(self, value: int) -> int: def validate_highlight_color(self, value: str) -> str:
if value < 1: return validate_highlight_color(value)
raise serializers.ValidationError("Page must be a positive integer.")
def validate_epub_cfi(self, value: str) -> str:
stripped = (value or "").strip()
if not stripped:
raise serializers.ValidationError("epub_cfi is required.")
return stripped
def validate_chapter_index(self, value: int) -> int:
if value < 0:
raise serializers.ValidationError("chapter_index must be non-negative.")
return value return value
def validate(self, attrs): def validate(self, attrs):
user = self.context["request"].user user = self.context["request"].user
if Bookmark.objects.filter( ebook = attrs["ebook"]
user=user, book=attrs["book"], page=attrs["page"] epub_cfi = attrs["epub_cfi"]
).exists(): if Bookmark.objects.filter(user=user, ebook=ebook, epub_cfi=epub_cfi).exists():
raise serializers.ValidationError( raise serializers.ValidationError(
{"page": "A bookmark already exists at this page for this book."} {"epub_cfi": "A marker already exists for this passage."}
) )
return attrs return attrs
def create(self, validated_data): def create(self, validated_data):
validated_data["user"] = self.context["request"].user validated_data["user"] = self.context["request"].user
validated_data["page"] = validated_data.get("chapter_index", 0) + 1
validated_data["content"] = (validated_data.get("content") or "").strip()
return super().create(validated_data) return super().create(validated_data)
class NoteSerializer(serializers.ModelSerializer): class NoteSerializer(serializers.ModelSerializer):
"""Serialize Note data with full details."""
book_title = serializers.CharField(source="book.title", read_only=True) book_title = serializers.CharField(source="book.title", read_only=True)
class Meta: class Meta:
@@ -78,16 +102,8 @@ class NoteSerializer(serializers.ModelSerializer):
raise serializers.ValidationError("Page must be a positive integer.") raise serializers.ValidationError("Page must be a positive integer.")
return value return value
def validate_content(self, value: str) -> str:
stripped = value.strip()
if not stripped:
raise serializers.ValidationError("Note content cannot be empty.")
return stripped
class NoteCreateSerializer(serializers.ModelSerializer): class NoteCreateSerializer(serializers.ModelSerializer):
"""Serializer used for creating notes. Sets user from request context."""
class Meta: class Meta:
model = Note model = Note
fields = ["book", "page", "location_text", "content"] fields = ["book", "page", "location_text", "content"]
@@ -105,4 +121,4 @@ class NoteCreateSerializer(serializers.ModelSerializer):
def create(self, validated_data): def create(self, validated_data):
validated_data["user"] = self.context["request"].user validated_data["user"] = self.context["request"].user
return super().create(validated_data) return super().create(validated_data)
+66 -231
View File
@@ -6,14 +6,10 @@ from rest_framework import status
from rest_framework.test import APIClient from rest_framework.test import APIClient
from apps.annotations.models import Bookmark, Note from apps.annotations.models import Bookmark, Note
from apps.books.models import Book from apps.books.models import Book, EBook
from apps.users.models import User from apps.users.models import User
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture @pytest.fixture
def api_client() -> APIClient: def api_client() -> APIClient:
return APIClient() return APIClient()
@@ -53,12 +49,26 @@ def book() -> Book:
@pytest.fixture @pytest.fixture
def bookmark(auth_client, user: User, book: Book) -> Bookmark: def ebook(user: User) -> EBook:
return EBook.objects.create(
user=user,
title="Test Ebook",
author="Test Author",
format="epub",
)
@pytest.fixture
def bookmark(auth_client, user: User, ebook: EBook) -> Bookmark:
return Bookmark.objects.create( return Bookmark.objects.create(
user=user, user=user,
book=book, ebook=ebook,
page=42, epub_cfi="epubcfi(/6/4!/4/2,/1:0,/1:10)",
chapter_index=2,
chapter_title="Chapter 3",
page=3,
location_text="important passage", location_text="important passage",
content="",
) )
@@ -73,259 +83,84 @@ def note(auth_client, user: User, book: Book) -> Note:
) )
# ---------------------------------------------------------------------------
# Bookmark tests
# ---------------------------------------------------------------------------
class TestBookmarkList: class TestBookmarkList:
url = reverse("bookmark-list") url = reverse("bookmark-list")
def test_unauthenticated_user_cannot_list(self, api_client: APIClient): def test_list_requires_auth(self, api_client: APIClient):
response = api_client.get(self.url) response = api_client.get(self.url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_list_returns_user_bookmarks_only( def test_list_returns_user_bookmarks_only(
self, auth_client: APIClient, user: User, other_user: User, book: Book self, auth_client: APIClient, user: User, other_user: User, ebook: EBook
): ):
Bookmark.objects.create(user=user, book=book, page=1) Bookmark.objects.create(
Bookmark.objects.create(user=other_user, book=book, page=2) user=user,
ebook=ebook,
epub_cfi="epubcfi(/6/4!/4/2,/1:0,/1:5)",
chapter_index=0,
page=1,
)
other_ebook = EBook.objects.create(user=other_user, title="Other", format="epub")
Bookmark.objects.create(
user=other_user,
ebook=other_ebook,
epub_cfi="epubcfi(/6/4!/4/2,/2:0,/2:5)",
chapter_index=0,
page=1,
)
response = auth_client.get(self.url) response = auth_client.get(self.url)
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
results = response.data["results"] results = response.data["results"]
assert len(results) == 1 assert len(results) == 1
assert results[0]["page"] == 1
def test_list_returns_empty_when_no_bookmarks( def test_filter_by_ebook(self, auth_client: APIClient, bookmark: Bookmark, ebook: EBook):
self, auth_client: APIClient response = auth_client.get(self.url, {"ebook": str(ebook.id)})
):
response = auth_client.get(self.url)
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
assert response.data["count"] == 0 assert len(response.data["results"]) == 1
def test_list_orders_by_newest_first(
self, auth_client: APIClient, user: User, book: Book
):
b1 = Bookmark.objects.create(user=user, book=book, page=1)
b2 = Bookmark.objects.create(user=user, book=book, page=2)
response = auth_client.get(self.url)
results = response.data["results"]
assert results[0]["page"] == 2
assert results[1]["page"] == 1
def test_list_includes_book_title(
self, auth_client: APIClient, bookmark: Bookmark
):
response = auth_client.get(self.url)
assert response.status_code == status.HTTP_200_OK
assert response.data["results"][0]["book_title"] == "Test Book"
class TestBookmarkCreate: class TestBookmarkCreate:
url = reverse("bookmark-list") url = reverse("bookmark-list")
def test_create_bookmark(self, auth_client: APIClient, book: Book): def test_create_marker(self, auth_client: APIClient, ebook: EBook):
data = {"book": str(book.id), "page": 10, "location_text": "key insight"} data = {
"ebook": ebook.id,
"epub_cfi": "epubcfi(/6/4!/4/2,/1:0,/1:20)",
"chapter_index": 1,
"chapter_title": "Chapter 2",
"location_text": "Selected text",
"content": "My thought",
}
response = auth_client.post(self.url, data, format="json") response = auth_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_201_CREATED assert response.status_code == status.HTTP_201_CREATED
assert response.data["page"] == 10 assert response.data["content"] == "My thought"
assert response.data["ebook"] == ebook.id
def test_create_bookmark_without_location_text( def test_create_bookmark_only_empty_content(self, auth_client: APIClient, ebook: EBook):
self, auth_client: APIClient, book: Book data = {
): "ebook": ebook.id,
data = {"book": str(book.id), "page": 5} "epub_cfi": "epubcfi(/6/4!/4/2,/3:0,/3:8)",
"chapter_index": 0,
"location_text": "Quote only",
"content": "",
}
response = auth_client.post(self.url, data, format="json") response = auth_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_201_CREATED assert response.status_code == status.HTTP_201_CREATED
assert response.data["page"] == 5 assert response.data["content"] == ""
def test_duplicate_bookmark_page_is_rejected( def test_duplicate_cfi_rejected(self, auth_client: APIClient, bookmark: Bookmark, ebook: EBook):
self, auth_client: APIClient, bookmark: Bookmark data = {
): "ebook": ebook.id,
data = {"book": str(bookmark.book.id), "page": bookmark.page} "epub_cfi": bookmark.epub_cfi,
response = auth_client.post(self.url, data, format="json") "chapter_index": 0,
assert response.status_code == status.HTTP_400_BAD_REQUEST "location_text": "dup",
}
def test_unauthenticated_user_cannot_create(
self, api_client: APIClient, book: Book
):
data = {"book": str(book.id), "page": 10}
response = api_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_invalid_page_rejected(
self, auth_client: APIClient, book: Book
):
data = {"book": str(book.id), "page": 0}
response = auth_client.post(self.url, data, format="json") response = auth_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.status_code == status.HTTP_400_BAD_REQUEST
class TestBookmarkDetail: class TestBookmarkDetail:
def test_get_bookmark( def test_delete_bookmark(self, auth_client: APIClient, bookmark: Bookmark):
self, auth_client: APIClient, bookmark: Bookmark
):
url = reverse("bookmark-detail", args=[str(bookmark.id)])
response = auth_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert response.data["page"] == bookmark.page
def test_cannot_access_other_users_bookmark(
self, api_client: APIClient, other_user: User, bookmark: Bookmark
):
api_client.force_authenticate(user=other_user)
url = reverse("bookmark-detail", args=[str(bookmark.id)])
response = api_client.get(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
class TestBookmarkDelete:
def test_delete_bookmark(
self, auth_client: APIClient, bookmark: Bookmark
):
url = reverse("bookmark-detail", args=[str(bookmark.id)]) url = reverse("bookmark-detail", args=[str(bookmark.id)])
response = auth_client.delete(url) response = auth_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT assert response.status_code == status.HTTP_204_NO_CONTENT
assert Bookmark.objects.count() == 0
def test_cannot_delete_other_users_bookmark(
self, api_client: APIClient, other_user: User, bookmark: Bookmark
):
api_client.force_authenticate(user=other_user)
url = reverse("bookmark-detail", args=[str(bookmark.id)])
response = api_client.delete(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
class TestBookmarkFilterByBook:
def test_filter_by_book(
self, auth_client: APIClient, user: User, book: Book
):
other_book = Book.objects.create(title="Other", author="Other")
Bookmark.objects.create(user=user, book=book, page=1)
Bookmark.objects.create(user=user, book=other_book, page=2)
url = reverse("bookmark-list")
response = auth_client.get(url, {"book": str(book.id)})
assert response.status_code == status.HTTP_200_OK
assert response.data["count"] == 1
assert response.data["results"][0]["page"] == 1
# ---------------------------------------------------------------------------
# Note tests
# ---------------------------------------------------------------------------
class TestNoteList:
url = reverse("note-list")
def test_unauthenticated_user_cannot_list(self, api_client: APIClient):
response = api_client.get(self.url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_list_returns_user_notes_only(
self, auth_client: APIClient, user: User, other_user: User, book: Book
):
Note.objects.create(user=user, book=book, page=1, content="My note")
Note.objects.create(user=other_user, book=book, page=2, content="Other's note")
response = auth_client.get(self.url)
assert response.status_code == status.HTTP_200_OK
results = response.data["results"]
assert len(results) == 1
assert results[0]["content"] == "My note"
def test_list_includes_book_title(
self, auth_client: APIClient, note: Note
):
response = auth_client.get(self.url)
assert response.status_code == status.HTTP_200_OK
assert response.data["results"][0]["book_title"] == "Test Book"
class TestNoteCreate:
url = reverse("note-list")
def test_create_note(self, auth_client: APIClient, book: Book):
data = {
"book": str(book.id),
"page": 20,
"location_text": "interesting part",
"content": "This is a thoughtful note.",
}
response = auth_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_201_CREATED
assert response.data["content"] == "This is a thoughtful note."
def test_create_note_without_location_text(
self, auth_client: APIClient, book: Book
):
data = {"book": str(book.id), "page": 20, "content": "A note."}
response = auth_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_201_CREATED
def test_empty_content_rejected(
self, auth_client: APIClient, book: Book
):
data = {"book": str(book.id), "page": 20, "content": " "}
response = auth_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_unauthenticated_user_cannot_create(
self, api_client: APIClient, book: Book
):
data = {"book": str(book.id), "page": 20, "content": "Note"}
response = api_client.post(self.url, data, format="json")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
class TestNoteUpdate:
def test_update_note_content(
self, auth_client: APIClient, note: Note
):
url = reverse("note-detail", args=[str(note.id)])
data = {"content": "Updated note content."}
response = auth_client.patch(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data["content"] == "Updated note content."
def test_cannot_update_other_users_note(
self, api_client: APIClient, other_user: User, note: Note
):
api_client.force_authenticate(user=other_user)
url = reverse("note-detail", args=[str(note.id)])
data = {"content": "Hacked!"}
response = api_client.patch(url, data, format="json")
assert response.status_code == status.HTTP_404_NOT_FOUND
class TestNoteDelete:
def test_delete_note(self, auth_client: APIClient, note: Note):
url = reverse("note-detail", args=[str(note.id)])
response = auth_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert Note.objects.count() == 0
def test_batch_delete_notes(
self, auth_client: APIClient, user: User, book: Book
):
n1 = Note.objects.create(user=user, book=book, page=1, content="A")
n2 = Note.objects.create(user=user, book=book, page=2, content="B")
url = reverse("note-batch-delete")
response = auth_client.delete(url, {"ids": [str(n1.id), str(n2.id)]}, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data["deleted"] == 2
class TestNoteFilterByBook:
def test_filter_by_book(
self, auth_client: APIClient, user: User, book: Book
):
other_book = Book.objects.create(title="Other", author="Other")
Note.objects.create(user=user, book=book, page=1, content="In book")
Note.objects.create(user=user, book=other_book, page=2, content="In other")
url = reverse("note-list")
response = auth_client.get(url, {"book": str(book.id)})
assert response.status_code == status.HTTP_200_OK
assert response.data["count"] == 1
assert response.data["results"][0]["content"] == "In book"
+10 -20
View File
@@ -16,14 +16,14 @@ from apps.annotations.serializers import (
class BookmarkViewSet(viewsets.ModelViewSet): class BookmarkViewSet(viewsets.ModelViewSet):
"""CRUD for user bookmarks. Users can only manage their own bookmarks.""" """CRUD for user ebook markers (passage anchors + optional thoughts)."""
permission_classes = [IsAuthenticated, IsOwner] permission_classes = [IsAuthenticated, IsOwner]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_fields = ["book"] filterset_fields = ["ebook"]
search_fields = ["location_text"] search_fields = ["location_text", "content", "chapter_title"]
ordering_fields = ["created_at", "page"] ordering_fields = ["chapter_index", "epub_cfi", "created_at"]
ordering = ["-created_at"] ordering = ["chapter_index", "epub_cfi"]
def get_serializer_class(self): def get_serializer_class(self):
if self.action == "create": if self.action == "create":
@@ -31,16 +31,13 @@ class BookmarkViewSet(viewsets.ModelViewSet):
return BookmarkSerializer return BookmarkSerializer
def get_queryset(self): def get_queryset(self):
return Bookmark.objects.filter(user=self.request.user).select_related( return Bookmark.objects.filter(user=self.request.user).select_related("ebook")
"book"
)
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(user=self.request.user) serializer.save(user=self.request.user)
@action(detail=False, methods=["delete"], url_path="batch-delete") @action(detail=False, methods=["delete"], url_path="batch-delete")
def batch_delete(self, request): def batch_delete(self, request):
"""Delete multiple bookmarks by id list."""
ids = request.data.get("ids", []) ids = request.data.get("ids", [])
if not ids: if not ids:
return Response( return Response(
@@ -49,13 +46,11 @@ class BookmarkViewSet(viewsets.ModelViewSet):
deleted, _ = Bookmark.objects.filter( deleted, _ = Bookmark.objects.filter(
id__in=ids, user=request.user id__in=ids, user=request.user
).delete() ).delete()
return Response( return Response({"deleted": deleted}, status=status.HTTP_200_OK)
{"deleted": deleted}, status=status.HTTP_200_OK
)
class NoteViewSet(viewsets.ModelViewSet): class NoteViewSet(viewsets.ModelViewSet):
"""CRUD for user notes. Users can only manage their own notes.""" """Legacy notes API (catalog Book FK)."""
permission_classes = [IsAuthenticated, IsOwner] permission_classes = [IsAuthenticated, IsOwner]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
@@ -70,16 +65,13 @@ class NoteViewSet(viewsets.ModelViewSet):
return NoteSerializer return NoteSerializer
def get_queryset(self): def get_queryset(self):
return Note.objects.filter(user=self.request.user).select_related( return Note.objects.filter(user=self.request.user).select_related("book")
"book"
)
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(user=self.request.user) serializer.save(user=self.request.user)
@action(detail=False, methods=["delete"], url_path="batch-delete") @action(detail=False, methods=["delete"], url_path="batch-delete")
def batch_delete(self, request): def batch_delete(self, request):
"""Delete multiple notes by id list."""
ids = request.data.get("ids", []) ids = request.data.get("ids", [])
if not ids: if not ids:
return Response( return Response(
@@ -88,6 +80,4 @@ class NoteViewSet(viewsets.ModelViewSet):
deleted, _ = Note.objects.filter( deleted, _ = Note.objects.filter(
id__in=ids, user=request.user id__in=ids, user=request.user
).delete() ).delete()
return Response( return Response({"deleted": deleted}, status=status.HTTP_200_OK)
{"deleted": deleted}, status=status.HTTP_200_OK
)
@@ -0,0 +1,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),
),
]
@@ -0,0 +1,52 @@
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("books", "0003_readingprogress_epub_location"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="ReadingSchedule",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("meeting_count", models.PositiveIntegerField(default=4)),
("confirmed", models.BooleanField(default=False)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("ebook", models.ForeignKey(on_delete=models.CASCADE, related_name="schedules", to="books.ebook")),
("user", models.ForeignKey(on_delete=models.CASCADE, related_name="schedules", to=settings.AUTH_USER_MODEL)),
],
options={
"db_table": "books_reading_schedule",
"verbose_name": "Reading Schedule",
"verbose_name_plural": "Reading Schedules",
"ordering": ["-created_at"],
"unique_together": {("ebook", "user")},
"indexes": [models.Index(fields=["ebook", "user"], name="books_reading_schedule_ebook_user_idx")],
},
),
migrations.CreateModel(
name="MeetingSection",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("meeting_index", models.PositiveIntegerField()),
("title", models.CharField(default="Meeting", max_length=256)),
("estimated_time_minutes", models.PositiveIntegerField(default=0)),
("chapter_ids", models.JSONField(default=list)),
("schedule", models.ForeignKey(on_delete=models.CASCADE, related_name="meetings", to="books.readingschedule")),
],
options={
"db_table": "books_meeting_section",
"verbose_name": "Meeting Section",
"verbose_name_plural": "Meeting Sections",
"ordering": ["meeting_index"],
"unique_together": {("schedule", "meeting_index")},
"indexes": [models.Index(fields=["schedule", "meeting_index"], name="books_meeting_section_schedule_meeting_idx")],
},
),
]
+113 -23
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,75 @@ 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
from datetime import datetime
client_dt = datetime.fromisoformat(client_updated_at.replace("Z", "+00:00"))
if is_naive(client_dt):
client_dt = make_aware(client_dt)
if client_dt <= self.updated_at:
return self, False
except (ValueError, TypeError):
pass
self.current_position = position
self.last_page = last_page
self.device_id = device_id
self.device_name = device_name
self.version += 1
self.save(update_fields=[
"current_position", "last_page",
"device_id", "device_name", "version", "updated_at",
])
return self, True
class ReadingSchedule(models.Model):
"""A reading schedule that groups sections into weekly meetings for an ebook."""
ebook = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name="schedules")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="schedules")
meeting_count = models.PositiveIntegerField(default=4)
confirmed = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
db_table = "books_reading_settings" db_table = "books_reading_schedule"
verbose_name_plural = "reading settings" verbose_name = "Reading Schedule"
verbose_name_plural = "Reading Schedules"
ordering = ["-created_at"]
unique_together = [("ebook", "user")]
indexes = [models.Index(fields=["ebook", "user"])]
def __str__(self): def __str__(self):
return f"Settings for {self.user}" return f"Schedule for {self.ebook.title} ({self.meeting_count} meetings)"
class MeetingSection(models.Model):
"""A single meeting within a reading schedule, containing assigned chapters."""
schedule = models.ForeignKey(ReadingSchedule, on_delete=models.CASCADE, related_name="meetings")
meeting_index = models.PositiveIntegerField()
title = models.CharField(max_length=256, default="Meeting")
estimated_time_minutes = models.PositiveIntegerField(default=0)
chapter_ids = models.JSONField(default=list)
class Meta:
db_table = "books_meeting_section"
verbose_name = "Meeting Section"
verbose_name_plural = "Meeting Sections"
ordering = ["meeting_index"]
unique_together = [("schedule", "meeting_index")]
indexes = [models.Index(fields=["schedule", "meeting_index"])]
def __str__(self):
return f"Meeting {self.meeting_index + 1}: {self.title}"
+193 -22
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, MeetingSection, ReadingProgress, ReadingSchedule, 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,105 @@ 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.")
def get_file_url(self, obj):
request = self.context.get("request")
if request and obj.ebook.file:
return request.build_absolute_uri(obj.ebook.file.url)
return ""
def get_progress(self, obj):
try:
rp = obj.ebook.reading_progress
return {
"current_position": rp.current_position,
"last_page": rp.last_page,
"epub_location": rp.epub_location,
}
except ReadingProgress.DoesNotExist:
return None
class StorageSummarySerializer(serializers.Serializer):
total_downloads = serializers.IntegerField()
total_size_bytes = serializers.IntegerField()
ebooks = serializers.ListField(child=serializers.DictField())
class MeetingSectionSerializer(serializers.ModelSerializer):
chapters = serializers.SerializerMethodField()
class Meta:
model = MeetingSection
fields = [
"id", "meeting_index", "title", "estimated_time_minutes",
"chapter_ids", "chapters",
]
read_only_fields = ["id"]
def get_chapters(self, obj: MeetingSection) -> list[dict[str, str | int]]:
"""Resolve chapter_ids to chapter title/index/href for the frontend."""
if not obj.chapter_ids:
return []
chapters_qs = BookChapter.objects.filter(
id__in=obj.chapter_ids,
ebook=obj.schedule.ebook,
).order_by("index").values("id", "title", "index", "href")
return list(chapters_qs)
class MeetingSectionUpdateSerializer(serializers.Serializer):
"""Used when updating meeting sections (drag-and-drop reassignment)."""
meeting_id = serializers.IntegerField(required=False)
meeting_index = serializers.IntegerField(required=False)
chapter_ids = serializers.ListField(child=serializers.IntegerField(), required=False)
title = serializers.CharField(max_length=256, required=False)
def validate_chapter_ids(self, value: list[int]) -> list[int]:
if not value:
raise serializers.ValidationError("chapter_ids must not be empty.")
return value return value
def validate_font_style(self, value):
valid = [s.value for s in FontStyle] class ReadingScheduleSerializer(serializers.ModelSerializer):
if value not in valid: meetings = MeetingSectionSerializer(many=True, read_only=True)
raise serializers.ValidationError(f"Font style must be one of: {', '.join(valid)}")
class Meta:
model = ReadingSchedule
fields = [
"id", "ebook", "meeting_count", "confirmed",
"meetings", "created_at", "updated_at",
]
read_only_fields = ["id", "ebook", "created_at", "updated_at"]
class GenerateRecommendationsSerializer(serializers.Serializer):
meeting_count = serializers.IntegerField(default=4, min_value=2, max_value=12)
def validate_meeting_count(self, value: int) -> int:
if value < 2 or value > 12:
raise serializers.ValidationError("Meeting count must be between 2 and 12.")
return value return value
def validate_background_color(self, value):
valid = [c.value for c in BackgroundColor] class ConfirmScheduleSerializer(serializers.Serializer):
if value not in valid: confirm = serializers.BooleanField(default=True)
raise serializers.ValidationError(f"Background color must be one of: {', '.join(valid)}")
return value
@@ -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
@@ -0,0 +1,229 @@
"""Recommendation engine for splitting book chapters into balanced weekly meetings.
Algorithm:
1. Gather all BookChapters for an ebook (flat list, respecting hierarchy).
2. For each chapter, estimate reading time based on content length or default weight.
3. Partition chapters into N meetings (default 4) minimizing per-meeting time variance
while respecting chapter boundaries (never split a chapter).
4. Optionally prefer natural boundaries: if a chapter's children exist,
keep siblings together where possible.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any
from apps.books.models import BookChapter, EBook, MeetingSection, ReadingSchedule
logger = logging.getLogger(__name__)
DEFAULT_MEETING_COUNT = 4
@dataclass
class ChapterWithWeight:
chapter_id: int
title: str
href: str
index: int
weight: float # estimated reading time weight
children: list[int] = field(default_factory=list)
def _collect_chapters(ebook: EBook) -> list[ChapterWithWeight]:
"""Collect all chapters for an ebook as a flat, ordered list with weights."""
chapters_qs = (
BookChapter.objects.filter(ebook=ebook)
.order_by("index")
.prefetch_related("ebook")
)
chapters = list(chapters_qs)
# Build a map of chapter id -> ChapterWithWeight
result: list[ChapterWithWeight] = []
for ch in chapters:
weight = _estimate_chapter_weight(ch)
result.append(
ChapterWithWeight(
chapter_id=ch.id,
title=ch.title,
href=ch.href or "",
index=ch.index,
weight=weight,
)
)
return result
def _estimate_chapter_weight(chapter: BookChapter) -> float:
"""Estimate reading time weight for a chapter.
Defaults to 1.0 per chapter. Sub-chapters (those with non-empty children)
get a weight proportional to their child count + 1.
"""
children = chapter.children or []
child_count = len(children)
if child_count > 0:
return float(child_count + 1)
return 1.0
def _partition_chapters(
chapters: list[ChapterWithWeight],
num_meetings: int = DEFAULT_MEETING_COUNT,
) -> list[list[ChapterWithWeight]]:
"""Partition chapters into `num_meetings` groups minimizing weight variance.
Uses a greedy approach: compute target weight per meeting, then place
chapters sequentially, starting a new meeting when cumulative weight
exceeds the target and adding the overflow to the next meeting.
Never splits a chapter — boundaries are always at chapter edges.
"""
if not chapters:
return [[] for _ in range(num_meetings)]
total_weight = sum(ch.weight for ch in chapters)
target_weight = total_weight / num_meetings
meetings: list[list[ChapterWithWeight]] = []
current_meeting: list[ChapterWithWeight] = []
current_weight = 0.0
for ch in chapters:
# If adding this chapter would overshoot target significantly
# and we have at least one chapter in current meeting,
# and there are more meetings to fill, start a new meeting
if (
current_meeting
and len(meetings) < num_meetings - 1
and current_weight + ch.weight > target_weight * 1.4
and current_weight >= target_weight * 0.5
):
meetings.append(current_meeting)
current_meeting = []
current_weight = 0.0
current_meeting.append(ch)
current_weight += ch.weight
# Add the last meeting
if current_meeting:
meetings.append(current_meeting)
# Pad or balance: if we have fewer meetings than requested, split the largest
while len(meetings) < num_meetings and len(meetings) >= 1:
# Find the largest meeting to split
largest_idx = max(
range(len(meetings)),
key=lambda i: sum(c.weight for c in meetings[i]),
)
largest = meetings[largest_idx]
if len(largest) <= 1:
break
mid = len(largest) // 2
meetings[largest_idx] = largest[:mid]
meetings.insert(largest_idx + 1, largest[mid:])
# If we have more meetings than requested (shouldn't normally happen),
# merge the smallest adjacent pair
while len(meetings) > num_meetings:
best_merge = min(
range(len(meetings) - 1),
key=lambda i: sum(c.weight for c in meetings[i])
+ sum(c.weight for c in meetings[i + 1]),
)
merged = meetings[best_merge] + meetings[best_merge + 1]
meetings[best_merge] = merged
del meetings[best_merge + 1]
return meetings
def generate_recommendations(
ebook: EBook,
user,
meeting_count: int = DEFAULT_MEETING_COUNT,
) -> ReadingSchedule:
"""Generate section-based reading recommendations for an ebook.
Creates (or replaces) a ReadingSchedule with MeetingSections.
Args:
ebook: The EBook to generate recommendations for.
user: The requesting user.
meeting_count: Number of weekly meetings (default 4).
Returns:
The created ReadingSchedule instance.
"""
chapters = _collect_chapters(ebook)
# Delete any existing schedule for this ebook/user
ReadingSchedule.objects.filter(ebook=ebook, user=user).delete()
schedule = ReadingSchedule.objects.create(
ebook=ebook,
user=user,
meeting_count=meeting_count,
)
if not chapters:
# Create empty meetings
for i in range(meeting_count):
MeetingSection.objects.create(
schedule=schedule,
meeting_index=i,
title=f"Week {i + 1}",
)
return schedule
partition = _partition_chapters(chapters, meeting_count)
for meeting_idx, meeting_chapters in enumerate(partition):
chapter_ids = [ch.chapter_id for ch in meeting_chapters]
total_weight = sum(ch.weight for ch in meeting_chapters)
# Build a descriptive title from first and last chapter
first = meeting_chapters[0].title if meeting_chapters else "No chapters"
last = meeting_chapters[-1].title if len(meeting_chapters) > 1 else ""
if last and last != first:
title = f"{first}{last}"
else:
title = first
# Truncate title if too long
if len(title) > 200:
title = title[:197] + "..."
# Estimate time: ~5 minutes per weight point (adjustable heuristic)
estimated_time = max(5, int(total_weight * 5))
MeetingSection.objects.create(
schedule=schedule,
meeting_index=meeting_idx,
title=title,
estimated_time_minutes=estimated_time,
chapter_ids=chapter_ids,
)
return schedule
def regenerate_recommendations(schedule: ReadingSchedule) -> ReadingSchedule:
"""Regenerate recommendations for an existing schedule."""
return generate_recommendations(
ebook=schedule.ebook,
user=schedule.user,
meeting_count=schedule.meeting_count,
)
def confirm_schedule(schedule: ReadingSchedule) -> ReadingSchedule:
"""Confirm/finalize a reading schedule."""
schedule.confirmed = True
schedule.save(update_fields=["confirmed", "updated_at"])
return schedule
+5 -7
View File
@@ -1,16 +1,14 @@
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, ScheduleViewSet, book_reading_settings_view
router = DefaultRouter() router = DefaultRouter()
router.register(r"ebooks", EBookViewSet, basename="ebook")
router.register(r"schedules", ScheduleViewSet, basename="schedule")
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"),
]
+308 -54
View File
@@ -4,21 +4,26 @@ 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, MeetingSection, ReadingProgress, ReadingSchedule
from apps.books.serializers import ( from apps.books.serializers import (
BookDetailSerializer, BookListSerializer, BookSerializer, BookChapterSerializer, BookDetailSerializer, BookListSerializer, BookReadingSettingsSerializer,
BookChapterSerializer, EBookContentSerializer, EBookDetailSerializer, BookSerializer, ConfirmScheduleSerializer, DownloadRecordSerializer, EBookContentSerializer,
EBookListSerializer, EBookTocSerializer, EBookUploadSerializer, EBookDetailSerializer, EBookListSerializer, EBookTocSerializer, EBookUploadSerializer,
ReadingProgressSerializer, ReadingSettingsSerializer, GenerateRecommendationsSerializer, MeetingSectionSerializer, MeetingSectionUpdateSerializer,
ReadingProgressSerializer, ReadingScheduleSerializer, 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 +61,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 +98,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 +135,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 +159,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 +233,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 +311,184 @@ 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()
return Response(serializer.data)
class IsScheduleOwner(permissions.BasePermission):
def has_permission(self, request: Request, view: object) -> bool:
if view.action == "create":
ebook_id = request.data.get("ebook") or request.query_params.get("ebook_id")
if ebook_id:
return EBook.objects.filter(id=ebook_id, user=request.user).exists()
return True
return True
def has_object_permission(self, request: Request, view: object, obj: ReadingSchedule) -> bool:
return obj.user == request.user
class ScheduleViewSet(viewsets.ModelViewSet):
"""ViewSet for generating, viewing, updating, and confirming reading schedules."""
permission_classes = [IsAuthenticated, IsScheduleOwner]
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
def get_serializer_class(self):
if self.action == "generate":
return GenerateRecommendationsSerializer
if self.action == "confirm":
return ConfirmScheduleSerializer
if self.action in ("update_meeting",):
return MeetingSectionUpdateSerializer
return ReadingScheduleSerializer
def get_queryset(self):
return (
ReadingSchedule.objects.filter(user=self.request.user)
.select_related("ebook")
.prefetch_related("meetings")
.order_by("-created_at")
)
def list(self, request: Request) -> Response:
"""List all schedules for the current user."""
queryset = self.get_queryset()
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
def retrieve(self, request: Request, pk: int | None = None) -> Response:
"""Get a single schedule with meetings."""
schedule = self.get_object()
serializer = self.get_serializer(schedule)
return Response(serializer.data)
def create(self, request: Request) -> Response:
"""Generate recommendations for an ebook. Expects {ebook_id, meeting_count?}."""
serializer = GenerateRecommendationsSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
serializer.save() meeting_count = serializer.validated_data["meeting_count"]
return Response(serializer.data)
ebook_id = request.data.get("ebook") or request.data.get("ebook_id")
if not ebook_id:
return Response(
{"error": "ebook (id) is required."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
ebook = EBook.objects.get(id=ebook_id, user=request.user)
except EBook.DoesNotExist:
return Response(
{"error": "EBook not found or not owned by you."},
status=status.HTTP_404_NOT_FOUND,
)
from apps.books.services.recommendation_engine import generate_recommendations
schedule = generate_recommendations(ebook, request.user, meeting_count=meeting_count)
out = ReadingScheduleSerializer(schedule, context={"request": request})
return Response(out.data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"])
def generate(self, request: Request, pk: int | None = None) -> Response:
"""Re-generate recommendations for an existing schedule."""
schedule = self.get_object()
serializer = GenerateRecommendationsSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
meeting_count = serializer.validated_data["meeting_count"]
from apps.books.services.recommendation_engine import regenerate_recommendations
schedule.meeting_count = meeting_count
schedule.save(update_fields=["meeting_count", "updated_at"])
schedule = regenerate_recommendations(schedule)
out = ReadingScheduleSerializer(schedule, context={"request": request})
return Response(out.data)
@action(detail=True, methods=["post"])
def confirm(self, request: Request, pk: int | None = None) -> Response:
"""Confirm/finalize a schedule."""
schedule = self.get_object()
serializer = ConfirmScheduleSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
from apps.books.services.recommendation_engine import confirm_schedule
schedule = confirm_schedule(schedule)
out = ReadingScheduleSerializer(schedule, context={"request": request})
return Response(out.data)
@action(detail=True, methods=["patch"], url_path="meetings/(?P<meeting_pk>[^/.]+)")
def update_meeting(self, request: Request, pk: int | None = None,
meeting_pk: str | None = None) -> Response:
"""Update a single meeting section (drag-and-drop reassignment)."""
schedule = self.get_object()
try:
meeting = schedule.meetings.get(pk=meeting_pk)
except MeetingSection.DoesNotExist:
return Response(
{"error": "Meeting section not found."},
status=status.HTTP_404_NOT_FOUND,
)
serializer = MeetingSectionUpdateSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
if "chapter_ids" in data:
meeting.chapter_ids = data["chapter_ids"]
if "title" in data:
meeting.title = data["title"]
if "meeting_index" in data:
meeting.meeting_index = data["meeting_index"]
# Recalculate estimated time
chapter_count = len(meeting.chapter_ids) if meeting.chapter_ids else 0
meeting.estimated_time_minutes = max(5, chapter_count * 5)
meeting.save(update_fields=["chapter_ids", "title", "meeting_index", "estimated_time_minutes"])
out = MeetingSectionSerializer(meeting, context={"request": request})
return Response(out.data)
@action(detail=True, methods=["patch"], url_path="reorder")
def reorder_meetings(self, request: Request, pk: int | None = None) -> Response:
"""Reorder meetings. Expects {meetings: [{id, meeting_index}, ...]}."""
schedule = self.get_object()
meetings_data = request.data.get("meetings", [])
if not isinstance(meetings_data, list):
return Response(
{"error": "meetings must be a list."},
status=status.HTTP_400_BAD_REQUEST,
)
meeting_map = {m.id: m for m in schedule.meetings.all()}
updated: list[MeetingSection] = []
for entry in meetings_data:
mid = entry.get("id")
new_index = entry.get("meeting_index")
if mid is None or new_index is None:
continue
m = meeting_map.get(mid)
if m is None:
continue
m.meeting_index = new_index
updated.append(m)
MeetingSection.objects.bulk_update(updated, ["meeting_index"])
schedule.refresh_from_db()
out = ReadingScheduleSerializer(schedule, context={"request": request})
return Response(out.data)
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
+8 -5
View File
@@ -1,8 +1,11 @@
from django.urls import include, path from django.urls import path
from rest_framework.routers import DefaultRouter from rest_framework_simplejwt.views import TokenRefreshView
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from apps.users.views import EmailTokenObtainPairView, RegisterView
urlpatterns = [ urlpatterns = [
path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), path("register/", RegisterView.as_view(), name="register"),
path("token/", EmailTokenObtainPairView.as_view(), name="token_obtain_pair"),
path("login/", EmailTokenObtainPairView.as_view(), name="login"),
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
] ]
+14
View File
@@ -0,0 +1,14 @@
from rest_framework import generics
from rest_framework.permissions import AllowAny
from rest_framework_simplejwt.views import TokenObtainPairView
from apps.users.serializers import EmailTokenObtainPairSerializer, RegisterSerializer
class RegisterView(generics.CreateAPIView):
serializer_class = RegisterSerializer
permission_classes = [AllowAny]
class EmailTokenObtainPairView(TokenObtainPairView):
serializer_class = EmailTokenObtainPairSerializer
+2 -1
View File
@@ -40,6 +40,7 @@ INSTALLED_APPS = [
"apps.users", "apps.users",
"apps.books", "apps.books",
"apps.annotations", "apps.annotations",
"apps.reader",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -144,7 +145,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
+9 -4
View File
@@ -1,6 +1,3 @@
import os
from pathlib import Path
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
@@ -29,6 +26,14 @@ class Settings(BaseSettings):
"http://localhost:3000", "http://localhost:3000",
] ]
# Open Library metadata enrichment
OPENLIBRARY_ENABLED: bool = True
OPENLIBRARY_PREFERRED_LANG: str = "es"
OPENLIBRARY_FALLBACK_LANG: str = "en"
OPENLIBRARY_TIMEOUT_SECONDS: float = 10.0
OPENLIBRARY_CONNECT_TIMEOUT_SECONDS: float = 15.0
OPENLIBRARY_USER_AGENT: str = "CloudReader/1.0 (https://github.com/cloud-reader)"
@property @property
def DATABASE_URL(self) -> str: def DATABASE_URL(self) -> str:
return ( return (
@@ -39,4 +44,4 @@ class Settings(BaseSettings):
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
settings = Settings() settings = Settings()
+8 -1
View File
@@ -1,3 +1,4 @@
from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
@@ -6,4 +7,10 @@ urlpatterns = [
path("api/auth/", include("apps.users.urls")), path("api/auth/", include("apps.users.urls")),
path("api/books/", include("apps.books.urls")), path("api/books/", include("apps.books.urls")),
path("api/annotations/", include("apps.annotations.urls")), path("api/annotations/", include("apps.annotations.urls")),
] path("api/reader/", include("apps.reader.urls")),
]
if settings.DEBUG:
from django.conf.urls.static import static
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+25
View File
@@ -0,0 +1,25 @@
[project]
name = "backend"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"coverage==7.6.10",
"django==5.1.7",
"django-cors-headers==4.6.0",
"django-filter==25.1",
"djangorestframework==3.15.2",
"djangorestframework-simplejwt==5.4.0",
"gunicorn==23.0.0",
"pillow>=11.0.0",
"psycopg2-binary==2.9.10",
"pydantic==2.10.5",
"pydantic-settings==2.7.1",
"pytest==8.3.4",
"pytest-cov==6.0.0",
"pytest-django==4.9.0",
"python-dotenv==1.0.1",
"httpx>=0.28.0",
"ebooklib>=0.18",
"beautifulsoup4>=4.12.0",
"pypdf>=5.0.0",
]
+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" },
]
+13 -14
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
@@ -54,5 +54,4 @@ services:
- backend - backend
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>
+10 -7
View File
@@ -11,20 +11,23 @@
"lint": "eslint ." "lint": "eslint ."
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.9",
"react-router-dom": "^7.1.0",
"pdfjs-dist": "^4.10.38",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.0", "react-i18n-lite": "^1.0.10",
"axios": "^1.7.9" "react-reader": "^2.0.15"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"jsdom": "^25.0.0",
"typescript": "~5.7.0", "typescript": "~5.7.0",
"vite": "^6.0.0", "vite": "^6.0.0",
"vitest": "^2.1.0", "vitest": "^2.1.0"
"@testing-library/react": "^16.2.0",
"@testing-library/jest-dom": "^6.6.3",
"jsdom": "^25.0.0"
} }
} }
+28 -16
View File
@@ -1,25 +1,24 @@
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 RecommendationPage = lazy(() => import("./pages/RecommendationPage").then((m) => ({ default: m.RecommendationPage })));
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 +28,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,7 +41,9 @@ 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="/books/:ebookId/schedule" element={<ProtectedRoute><RecommendationPage /></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="/bookmarks-notes/:bookId?" element={<ProtectedRoute><BookmarksNotesPage /></ProtectedRoute>} /> <Route path="/bookmarks-notes/:bookId?" element={<ProtectedRoute><BookmarksNotesPage /></ProtectedRoute>} />
@@ -50,9 +56,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;
}
+102 -6
View File
@@ -6,15 +6,18 @@ import type {
ContentResponse, ContentResponse,
EBookDetail, EBookDetail,
EBookListItem, EBookListItem,
GenerateRecommendationsRequest,
ReadingProgress, ReadingProgress,
ReadingSchedule,
ReadingSettings, ReadingSettings,
TocResponse, TocResponse,
} from "../types/book"; } from "../types/book";
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 +25,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 +94,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;
@@ -98,4 +133,65 @@ export const booksApi = {
const { data } = await api.patch<ReadingSettings>("/books/settings/", settingsData); const { data } = await api.patch<ReadingSettings>("/books/settings/", settingsData);
return data; return data;
}, },
};
export const schedulesApi = {
async getSchedules(): Promise<ReadingSchedule[]> {
const { data } = await api.get<ReadingSchedule[]>("/books/schedules/");
return data;
},
async getSchedule(id: number): Promise<ReadingSchedule> {
const { data } = await api.get<ReadingSchedule>(`/books/schedules/${id}/`);
return data;
},
async generateRecommendations(request: GenerateRecommendationsRequest): Promise<ReadingSchedule> {
const { data } = await api.post<ReadingSchedule>("/books/schedules/", {
ebook_id: request.ebook_id,
meeting_count: request.meeting_count ?? 4,
});
return data;
},
async regenerateRecommendations(scheduleId: number, meetingCount?: number): Promise<ReadingSchedule> {
const { data } = await api.post<ReadingSchedule>(`/books/schedules/${scheduleId}/generate/`, {
meeting_count: meetingCount ?? 4,
});
return data;
},
async confirmSchedule(scheduleId: number): Promise<ReadingSchedule> {
const { data } = await api.post<ReadingSchedule>(`/books/schedules/${scheduleId}/confirm/`, {
confirm: true,
});
return data;
},
async updateMeeting(
scheduleId: number,
meetingId: number,
update: { chapter_ids?: number[]; title?: string; meeting_index?: number },
): Promise<unknown> {
const { data } = await api.patch(
`/books/schedules/${scheduleId}/meetings/${meetingId}/`,
update,
);
return data;
},
async reorderMeetings(
scheduleId: number,
meetings: { id: number; meeting_index: number }[],
): Promise<ReadingSchedule> {
const { data } = await api.patch<ReadingSchedule>(
`/books/schedules/${scheduleId}/reorder/`,
{ meetings },
);
return data;
},
async deleteSchedule(id: number): Promise<void> {
await api.delete(`/books/schedules/${id}/`);
},
}; };
+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;
}
+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;
}
+177
View File
@@ -0,0 +1,177 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
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 navigate = useNavigate();
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>
<button
type="button"
className={styles.item}
role="menuitem"
disabled={busy}
onClick={() => {
navigate(`/books/${book.id}/schedule`);
onClose();
}}
>
📅 Schedule Reading
</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,23 +1,38 @@
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>
); );
} }
@@ -0,0 +1,90 @@
import { useState } from "react";
import { useTranslation } from "react-i18n-lite";
import {
highlightBackgroundStyle,
markerPassageStyle,
normalizeHighlightColor,
} from "@/constants/bookmarkHighlightColors";
const PASSAGE_COLLAPSE_CHARS = 200;
const THOUGHT_COLLAPSE_CHARS = 120;
interface CollapsibleMarkerPassageProps {
text: string;
highlightColor?: string | null;
className?: string;
}
export function CollapsibleMarkerPassage({
text,
highlightColor,
className = "annotation-quote marker-thread-passage",
}: CollapsibleMarkerPassageProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const collapsible = text.length > PASSAGE_COLLAPSE_CHARS;
const color = normalizeHighlightColor(highlightColor);
return (
<blockquote className={className} style={markerPassageStyle(color)}>
<span
className={
collapsible && !expanded
? "marker-passage-text marker-passage-text--clamped"
: "marker-passage-text"
}
style={highlightBackgroundStyle(color)}
>
&ldquo;{text}&rdquo;
</span>
{collapsible && (
<button
type="button"
className="marker-text-toggle"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
>
{expanded ? t("annotations.showLess") : t("annotations.showMore")}
</button>
)}
</blockquote>
);
}
interface CollapsibleMarkerThoughtProps {
text: string;
className?: string;
}
export function CollapsibleMarkerThought({
text,
className = "marker-thread-thought",
}: CollapsibleMarkerThoughtProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const collapsible = text.length > THOUGHT_COLLAPSE_CHARS;
return (
<div className="marker-thought-wrap">
<p
className={
collapsible && !expanded
? `${className} marker-thought-text marker-thought-text--clamped`
: `${className} marker-thought-text`
}
>
{text}
</p>
{collapsible && (
<button
type="button"
className="marker-text-toggle"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
>
{expanded ? t("annotations.showLess") : t("annotations.showMore")}
</button>
)}
</div>
);
}
@@ -0,0 +1,20 @@
import { useTranslation } from "react-i18n-lite";
interface MarkerPassageActionsProps {
onGoToPassage: () => void;
onDelete: () => void;
}
export function MarkerPassageActions({ onGoToPassage, onDelete }: MarkerPassageActionsProps) {
const { t } = useTranslation();
return (
<div className="annotation-actions">
<button type="button" className="btn btn-sm" onClick={onGoToPassage}>
{t("annotations.goToPassage")}
</button>
<button type="button" className="btn btn-sm btn-danger" onClick={onDelete}>
{t("common.delete")}
</button>
</div>
);
}
@@ -0,0 +1,108 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18n-lite";
import { useAnnotations } from "@/context/AnnotationsContext";
import type { MarkerEntry } from "@/types";
import {
CollapsibleMarkerPassage,
CollapsibleMarkerThought,
} from "@/components/annotations/CollapsibleMarkerText";
import { MarkerPassageActions } from "@/components/annotations/MarkerPassageActions";
interface MarkerThreadsViewProps {
ebookIdFilter?: string;
onGoToPassage: (ebookId: number, epubCfi: string) => void;
}
export function MarkerThreadsView({
ebookIdFilter,
onGoToPassage,
}: MarkerThreadsViewProps) {
const { t } = useTranslation();
const { markersByBook, loadBookmarks, removeBookmark, state } = useAnnotations();
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
useEffect(() => {
void loadBookmarks(ebookIdFilter);
}, [loadBookmarks, ebookIdFilter]);
const groups = useMemo(() => {
if (!ebookIdFilter) return markersByBook;
const id = Number(ebookIdFilter);
return markersByBook.filter((g) => g.ebookId === id);
}, [markersByBook, ebookIdFilter]);
if (state.bookmarksLoading) {
return <div className="annotations-loading">{t("annotations.loading")}</div>;
}
if (groups.length === 0) {
return (
<div className="annotations-empty">
<p>{t("annotations.empty")}</p>
<p style={{ fontSize: 14, color: "#6b7280", marginTop: 8 }}>{t("annotations.selectTextHint")}</p>
</div>
);
}
const toggleBook = (ebookId: number) => {
setCollapsed((prev) => ({ ...prev, [ebookId]: !prev[ebookId] }));
};
return (
<div className="marker-books-list">
{groups.map((group) => {
const isCollapsed = collapsed[group.ebookId] ?? false;
return (
<section key={group.ebookId} className="marker-book-group">
<button
type="button"
className="marker-book-header"
onClick={() => toggleBook(group.ebookId)}
>
<span className="marker-book-title">{group.ebookTitle}</span>
<span className="marker-book-count">
{t("annotations.markerCount", { count: String(group.markers.length) })}
</span>
<span className="marker-book-chevron">{isCollapsed ? "▸" : "▾"}</span>
</button>
{!isCollapsed && (
<ul className="marker-thread-list">
{group.markers.map((m: MarkerEntry) => (
<li key={m.id} className="marker-thread">
<div className="marker-thread-meta">
{m.chapter_title ? (
<span className="marker-thread-chapter">{m.chapter_title}</span>
) : (
<span className="marker-thread-chapter">
{t("annotations.chapterIndex", { index: String(m.chapter_index + 1) })}
</span>
)}
</div>
{m.location_text && (
<CollapsibleMarkerPassage
text={m.location_text}
highlightColor={m.highlight_color}
/>
)}
{m.content ? (
<CollapsibleMarkerThought
text={m.content}
className="marker-thread-thought marker-thread-reply"
/>
) : (
<span className="annotation-kind-badge bookmark-badge">{t("annotations.bookmarkOnly")}</span>
)}
<MarkerPassageActions
onGoToPassage={() => onGoToPassage(m.ebook_id, m.epub_cfi)}
onDelete={() => removeBookmark(m.id)}
/>
</li>
))}
</ul>
)}
</section>
);
})}
</div>
);
}
@@ -1,148 +0,0 @@
import React, { useState } from "react";
import { useAnnotations } from "@/context/AnnotationsContext";
import type { Note } from "@/types";
interface NoteListProps {
bookId?: string;
onNavigateToPage?: (bookId: string, page: number) => void;
}
export function NoteList({
bookId,
onNavigateToPage,
}: NoteListProps): React.ReactElement {
const { state, loadNotes, editNote, removeNote } = useAnnotations();
const [editingId, setEditingId] = useState<string | null>(null);
const [editContent, setEditContent] = useState<string>("");
const [deletingId, setDeletingId] = useState<string | null>(null);
const [savingId, setSavingId] = useState<string | null>(null);
React.useEffect(() => {
loadNotes(bookId);
}, [loadNotes, bookId]);
const handleEdit = (note: Note): void => {
setEditingId(note.id);
setEditContent(note.content);
};
const handleSave = async (id: string): Promise<void> => {
setSavingId(id);
try {
await editNote(id, editContent.trim());
setEditingId(null);
} catch {
// error handled by context
} finally {
setSavingId(null);
}
};
const handleCancelEdit = (): void => {
setEditingId(null);
setEditContent("");
};
const handleDelete = async (id: string): Promise<void> => {
setDeletingId(id);
try {
await removeNote(id);
} catch {
// error handled by context
} finally {
setDeletingId(null);
}
};
if (state.notesLoading) {
return <div className="annotations-loading">Loading notes...</div>;
}
if (state.notes.length === 0) {
return (
<div className="annotations-empty">
No notes yet. Select a passage and add a note while reading.
</div>
);
}
return (
<div className="annotations-list">
{state.notes.map((note: Note) => (
<div key={note.id} className="annotation-card">
<div className="annotation-card-header">
<span className="annotation-kind-badge note-badge">Note</span>
<span className="annotation-page">Page {note.page}</span>
<span className="annotation-date">
{new Date(note.created_at).toLocaleDateString()}
</span>
</div>
{note.location_text && (
<blockquote className="annotation-quote">
&ldquo;{note.location_text}&rdquo;
</blockquote>
)}
<div className="annotation-note-content">
{editingId === note.id ? (
<div className="note-edit-form">
<textarea
className="note-edit-textarea"
value={editContent}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setEditContent(e.target.value)
}
rows={4}
autoFocus
/>
<div className="note-edit-actions">
<button
className="btn btn-sm"
onClick={() => handleSave(note.id)}
disabled={savingId === note.id || !editContent.trim()}
>
{savingId === note.id ? "Saving..." : "Save"}
</button>
<button
className="btn btn-sm btn-secondary"
onClick={handleCancelEdit}
>
Cancel
</button>
</div>
</div>
) : (
<p className="note-content-text">{note.content}</p>
)}
</div>
<div className="annotation-actions">
{onNavigateToPage && (
<button
className="btn btn-sm"
onClick={() =>
onNavigateToPage(note.book, note.page)
}
>
Go to page
</button>
)}
{editingId !== note.id && (
<button
className="btn btn-sm btn-secondary"
onClick={() => handleEdit(note)}
>
Edit
</button>
)}
<button
className="btn btn-sm btn-danger"
onClick={() => handleDelete(note.id)}
disabled={deletingId === note.id}
>
{deletingId === note.id ? "Deleting..." : "Delete"}
</button>
</div>
</div>
))}
</div>
);
}
@@ -1,4 +0,0 @@
export { BookmarkList } from "./BookmarkList";
export { NoteList } from "./NoteList";
export { AddAnnotationForm } from "./AddAnnotationForm";
export { AnnotationsDashboard } from "./AnnotationsDashboard";
-27
View File
@@ -1,27 +0,0 @@
import React from "react";
import { AnnotationsProvider } from "@/context/AnnotationsContext";
interface LayoutProps {
children: React.ReactNode;
title?: string;
}
export function Layout({
children,
title = "Cloud Reader",
}: LayoutProps): React.ReactElement {
return (
<AnnotationsProvider>
<div className="app-container">
<header className="app-header">
<h1 className="app-title">{title}</h1>
<nav className="app-nav">
<a href="/" className="nav-link">Home</a>
<a href="/bookmarks-notes" className="nav-link">Bookmarks & Notes</a>
</nav>
</header>
<main className="app-main">{children}</main>
</div>
</AnnotationsProvider>
);
}
@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18n-lite";
interface SimpleFormPageLayoutProps {
title: string;
onBack: () => void;
children: ReactNode;
}
export function SimpleFormPageLayout({ title, onBack, children }: SimpleFormPageLayoutProps) {
const { t } = useTranslation();
return (
<div style={{ maxWidth: 500, margin: "0 auto", padding: 16, minHeight: "100vh", background: "#f8f9fa" }}>
<header style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 24, padding: "16px 0", borderBottom: "1px solid #eee" }}>
<button
type="button"
onClick={onBack}
style={{ padding: "8px 16px", borderRadius: 6, border: "1px solid #ddd", background: "#fff", cursor: "pointer", fontSize: 14 }}
>
{t("common.back")}
</button>
<h1 style={{ fontSize: 24, fontWeight: 700, color: "#1a1a2e", margin: 0 }}>{title}</h1>
</header>
{children}
</div>
);
}
-1
View File
@@ -1 +0,0 @@
export { Layout } from "./Layout";
@@ -0,0 +1,140 @@
.shelf {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 90;
background: #fff;
border-top: 1px solid #e5e7eb;
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.08);
transition: max-height 0.25s ease;
}
.shelfCollapsed {
max-height: 52px;
}
.shelfExpanded {
max-height: min(42vh, 320px);
}
.shelfHandle {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border: none;
background: #fafafa;
cursor: pointer;
font: inherit;
text-align: left;
}
.shelfHandle:hover {
background: #f3f4f6;
}
.shelfHandleTitle {
font-weight: 700;
font-size: 14px;
color: #1f2937;
}
.shelfHandleCount {
font-size: 13px;
color: #6b7280;
flex: 1;
}
.shelfChevron {
font-size: 14px;
color: #9333ea;
font-weight: 700;
}
.shelfBody {
overflow-y: auto;
max-height: calc(min(42vh, 320px) - 52px);
padding: 12px 16px 16px;
border-top: 1px solid #f3f4f6;
}
.shelfGrid {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 4px;
scroll-snap-type: x proximity;
}
.shelfCard {
flex: 0 0 120px;
scroll-snap-align: start;
cursor: pointer;
border-radius: 10px;
overflow: hidden;
background: #fff;
border: 1px solid #e5e7eb;
transition: transform 0.15s, box-shadow 0.15s;
}
.shelfCard:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.shelfCover {
position: relative;
height: 160px;
background: #f0f0f0;
}
.shelfCover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.shelfCoverPlaceholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 32px;
}
.shelfBadge {
position: absolute;
top: 6px;
right: 6px;
background: #f3e8ff;
color: #9333ea;
font-size: 9px;
font-weight: 700;
padding: 2px 6px;
border-radius: 999px;
}
.shelfMeta {
padding: 8px;
}
.shelfTitle {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #1f2937;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.shelfAuthor {
margin: 2px 0 0;
font-size: 11px;
color: #6b7280;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -0,0 +1,92 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18n-lite";
import styles from "./FinishedBooksShelf.module.css";
interface ShelfBook {
id: number;
title: string;
author: string;
cover_image: string | null;
format: string;
genre: string;
}
interface FinishedBooksShelfProps {
books: ShelfBook[];
defaultExpanded?: boolean;
onOpenBook: (book: ShelfBook) => void;
onContextMenu: (e: React.MouseEvent, book: ShelfBook) => void;
}
export function FinishedBooksShelf({
books,
defaultExpanded = false,
onOpenBook,
onContextMenu,
}: FinishedBooksShelfProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(defaultExpanded);
useEffect(() => {
if (defaultExpanded) setExpanded(true);
}, [defaultExpanded]);
if (books.length === 0) return null;
return (
<div
className={`${styles.shelf} ${expanded ? styles.shelfExpanded : styles.shelfCollapsed}`}
aria-label={t("library.finishedSection")}
>
<button
type="button"
className={styles.shelfHandle}
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
>
<span className={styles.shelfHandleTitle}>{t("library.finishedSection")}</span>
<span className={styles.shelfHandleCount}>
{t("library.finishedCount", { count: String(books.length) })}
</span>
<span className={styles.shelfChevron} aria-hidden>
{expanded ? "▾" : "▴"}
</span>
</button>
{expanded && (
<div className={styles.shelfBody}>
<div className={styles.shelfGrid}>
{books.map((book) => (
<div
key={book.id}
role="button"
tabIndex={0}
className={styles.shelfCard}
onClick={() => onOpenBook(book)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenBook(book);
}
}}
onContextMenu={(e) => onContextMenu(e, book)}
>
<div className={styles.shelfCover}>
{book.cover_image ? (
<img src={book.cover_image} alt="" />
) : (
<span className={styles.shelfCoverPlaceholder}>📖</span>
)}
<span className={styles.shelfBadge}>{t("library.readingStatus.finished")}</span>
</div>
<div className={styles.shelfMeta}>
<p className={styles.shelfTitle}>{book.title}</p>
<p className={styles.shelfAuthor}>{book.author}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

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