Archived
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ffa6e914e |
@@ -1,10 +0,0 @@
|
|||||||
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
@@ -1,40 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@@ -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 (web frontend)
|
├── frontend/ # React + Vite + TypeScript (canonical frontend)
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── api/ # API client (axios with JWT refresh)
|
│ │ ├── api/ # API client (axios with JWT refresh)
|
||||||
│ │ ├── components/ # Reusable components
|
│ │ ├── components/ # Reusable components
|
||||||
@@ -23,23 +23,6 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -47,9 +30,6 @@ 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
|
||||||
@@ -70,24 +50,8 @@ 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.
|
|
||||||
@@ -6,11 +6,3 @@ 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 +0,0 @@
|
|||||||
3.12
|
|
||||||
@@ -5,17 +5,9 @@ from apps.annotations.models import Bookmark, Note
|
|||||||
|
|
||||||
@admin.register(Bookmark)
|
@admin.register(Bookmark)
|
||||||
class BookmarkAdmin(admin.ModelAdmin):
|
class BookmarkAdmin(admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = ("user", "book", "page", "created_at")
|
||||||
"user",
|
list_select_related = ("user", "book")
|
||||||
"ebook",
|
search_fields = ("user__email", "book__title", "location_text")
|
||||||
"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",)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
# 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')],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# 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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -5,7 +5,7 @@ from django.db import models
|
|||||||
|
|
||||||
|
|
||||||
class Bookmark(models.Model):
|
class Bookmark(models.Model):
|
||||||
"""A saved passage anchor in an uploaded ebook (EPUB CFI + optional thought)."""
|
"""A saved location in a book that the user can return to."""
|
||||||
|
|
||||||
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,34 +14,18 @@ class Bookmark(models.Model):
|
|||||||
related_name="bookmarks",
|
related_name="bookmarks",
|
||||||
db_index=True,
|
db_index=True,
|
||||||
)
|
)
|
||||||
ebook = models.ForeignKey(
|
book = models.ForeignKey(
|
||||||
"books.EBook",
|
"books.Book",
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="bookmarks",
|
related_name="bookmarks",
|
||||||
db_index=True,
|
db_index=True,
|
||||||
)
|
)
|
||||||
epub_cfi = models.CharField(max_length=2048, db_index=True)
|
page = models.PositiveIntegerField()
|
||||||
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)
|
||||||
|
|
||||||
@@ -49,20 +33,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 = ["chapter_index", "epub_cfi"]
|
ordering = ["-created_at"]
|
||||||
constraints = [
|
constraints = [
|
||||||
models.UniqueConstraint(
|
models.UniqueConstraint(
|
||||||
fields=["user", "ebook", "epub_cfi"],
|
fields=["user", "book", "page"],
|
||||||
name="uq_bookmark_user_ebook_cfi",
|
name="uq_bookmark_user_book_page",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.user} @ {self.ebook} ch.{self.chapter_index}"
|
return f"{self.user} @ {self.book} p.{self.page}"
|
||||||
|
|
||||||
|
|
||||||
class Note(models.Model):
|
class Note(models.Model):
|
||||||
"""Legacy note model; new UX uses Bookmark.content instead."""
|
"""A user-written note attached to a specific location in a book."""
|
||||||
|
|
||||||
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(
|
||||||
|
|||||||
@@ -1,86 +1,62 @@
|
|||||||
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):
|
||||||
ebook_title = serializers.CharField(source="ebook.title", read_only=True)
|
"""Serialize Bookmark data with full details."""
|
||||||
|
|
||||||
|
book_title = serializers.CharField(source="book.title", read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Bookmark
|
model = Bookmark
|
||||||
fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
"ebook",
|
"book",
|
||||||
"ebook_title",
|
"book_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", "ebook_title", "page"]
|
read_only_fields = ["id", "created_at", "updated_at", "book_title"]
|
||||||
|
|
||||||
|
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 = [
|
fields = ["book", "page", "location_text"]
|
||||||
"ebook",
|
|
||||||
"epub_cfi",
|
|
||||||
"chapter_index",
|
|
||||||
"chapter_title",
|
|
||||||
"location_text",
|
|
||||||
"content",
|
|
||||||
"highlight_color",
|
|
||||||
]
|
|
||||||
|
|
||||||
def validate_highlight_color(self, value: str) -> str:
|
def validate_page(self, value: int) -> int:
|
||||||
return validate_highlight_color(value)
|
if value < 1:
|
||||||
|
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
|
||||||
ebook = attrs["ebook"]
|
if Bookmark.objects.filter(
|
||||||
epub_cfi = attrs["epub_cfi"]
|
user=user, book=attrs["book"], page=attrs["page"]
|
||||||
if Bookmark.objects.filter(user=user, ebook=ebook, epub_cfi=epub_cfi).exists():
|
).exists():
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
{"epub_cfi": "A marker already exists for this passage."}
|
{"page": "A bookmark already exists at this page for this book."}
|
||||||
)
|
)
|
||||||
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:
|
||||||
@@ -102,8 +78,16 @@ 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"]
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ 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, EBook
|
from apps.books.models import Book
|
||||||
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()
|
||||||
@@ -49,26 +53,12 @@ def book() -> Book:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def ebook(user: User) -> EBook:
|
def bookmark(auth_client, user: User, book: Book) -> Bookmark:
|
||||||
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,
|
||||||
ebook=ebook,
|
book=book,
|
||||||
epub_cfi="epubcfi(/6/4!/4/2,/1:0,/1:10)",
|
page=42,
|
||||||
chapter_index=2,
|
|
||||||
chapter_title="Chapter 3",
|
|
||||||
page=3,
|
|
||||||
location_text="important passage",
|
location_text="important passage",
|
||||||
content="",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -83,84 +73,259 @@ 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_list_requires_auth(self, api_client: APIClient):
|
def test_unauthenticated_user_cannot_list(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, ebook: EBook
|
self, auth_client: APIClient, user: User, other_user: User, book: Book
|
||||||
):
|
):
|
||||||
Bookmark.objects.create(
|
Bookmark.objects.create(user=user, book=book, page=1)
|
||||||
user=user,
|
Bookmark.objects.create(user=other_user, book=book, page=2)
|
||||||
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_filter_by_ebook(self, auth_client: APIClient, bookmark: Bookmark, ebook: EBook):
|
def test_list_returns_empty_when_no_bookmarks(
|
||||||
response = auth_client.get(self.url, {"ebook": str(ebook.id)})
|
self, auth_client: APIClient
|
||||||
|
):
|
||||||
|
response = auth_client.get(self.url)
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
assert len(response.data["results"]) == 1
|
assert response.data["count"] == 0
|
||||||
|
|
||||||
|
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_marker(self, auth_client: APIClient, ebook: EBook):
|
def test_create_bookmark(self, auth_client: APIClient, book: Book):
|
||||||
data = {
|
data = {"book": str(book.id), "page": 10, "location_text": "key insight"}
|
||||||
"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["content"] == "My thought"
|
assert response.data["page"] == 10
|
||||||
assert response.data["ebook"] == ebook.id
|
|
||||||
|
|
||||||
def test_create_bookmark_only_empty_content(self, auth_client: APIClient, ebook: EBook):
|
def test_create_bookmark_without_location_text(
|
||||||
data = {
|
self, auth_client: APIClient, book: Book
|
||||||
"ebook": ebook.id,
|
):
|
||||||
"epub_cfi": "epubcfi(/6/4!/4/2,/3:0,/3:8)",
|
data = {"book": str(book.id), "page": 5}
|
||||||
"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["content"] == ""
|
assert response.data["page"] == 5
|
||||||
|
|
||||||
def test_duplicate_cfi_rejected(self, auth_client: APIClient, bookmark: Bookmark, ebook: EBook):
|
def test_duplicate_bookmark_page_is_rejected(
|
||||||
data = {
|
self, auth_client: APIClient, bookmark: Bookmark
|
||||||
"ebook": ebook.id,
|
):
|
||||||
"epub_cfi": bookmark.epub_cfi,
|
data = {"book": str(bookmark.book.id), "page": bookmark.page}
|
||||||
"chapter_index": 0,
|
response = auth_client.post(self.url, data, format="json")
|
||||||
"location_text": "dup",
|
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": 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_delete_bookmark(self, auth_client: APIClient, bookmark: Bookmark):
|
def test_get_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"
|
||||||
@@ -16,14 +16,14 @@ from apps.annotations.serializers import (
|
|||||||
|
|
||||||
|
|
||||||
class BookmarkViewSet(viewsets.ModelViewSet):
|
class BookmarkViewSet(viewsets.ModelViewSet):
|
||||||
"""CRUD for user ebook markers (passage anchors + optional thoughts)."""
|
"""CRUD for user bookmarks. Users can only manage their own bookmarks."""
|
||||||
|
|
||||||
permission_classes = [IsAuthenticated, IsOwner]
|
permission_classes = [IsAuthenticated, IsOwner]
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||||
filterset_fields = ["ebook"]
|
filterset_fields = ["book"]
|
||||||
search_fields = ["location_text", "content", "chapter_title"]
|
search_fields = ["location_text"]
|
||||||
ordering_fields = ["chapter_index", "epub_cfi", "created_at"]
|
ordering_fields = ["created_at", "page"]
|
||||||
ordering = ["chapter_index", "epub_cfi"]
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
if self.action == "create":
|
if self.action == "create":
|
||||||
@@ -31,13 +31,16 @@ 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("ebook")
|
return Bookmark.objects.filter(user=self.request.user).select_related(
|
||||||
|
"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(
|
||||||
@@ -46,11 +49,13 @@ 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({"deleted": deleted}, status=status.HTTP_200_OK)
|
return Response(
|
||||||
|
{"deleted": deleted}, status=status.HTTP_200_OK
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class NoteViewSet(viewsets.ModelViewSet):
|
class NoteViewSet(viewsets.ModelViewSet):
|
||||||
"""Legacy notes API (catalog Book FK)."""
|
"""CRUD for user notes. Users can only manage their own notes."""
|
||||||
|
|
||||||
permission_classes = [IsAuthenticated, IsOwner]
|
permission_classes = [IsAuthenticated, IsOwner]
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||||
@@ -65,13 +70,16 @@ 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("book")
|
return Note.objects.filter(user=self.request.user).select_related(
|
||||||
|
"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(
|
||||||
@@ -80,4 +88,6 @@ 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({"deleted": deleted}, status=status.HTTP_200_OK)
|
return Response(
|
||||||
|
{"deleted": deleted}, status=status.HTTP_200_OK
|
||||||
|
)
|
||||||
-105
@@ -1,105 +0,0 @@
|
|||||||
# 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')},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -12,6 +12,19 @@ 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)
|
||||||
@@ -36,12 +49,8 @@ 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, db_index=True)
|
title = models.CharField(max_length=512)
|
||||||
author = models.CharField(max_length=256, blank=True, default="", db_index=True)
|
author = models.CharField(max_length=256, blank=True, default="")
|
||||||
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)
|
||||||
@@ -61,24 +70,6 @@ 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:
|
||||||
@@ -87,34 +78,11 @@ 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:
|
||||||
@@ -125,32 +93,17 @@ 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).
|
|
||||||
|
|
||||||
Returns (instance, applied) where applied is True if the update was applied.
|
class ReadingSettings(models.Model):
|
||||||
"""
|
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reading_settings")
|
||||||
if client_updated_at and self.updated_at:
|
font_size = models.IntegerField(default=18)
|
||||||
try:
|
font_style = models.CharField(max_length=20, choices=FontStyle.choices, default=FontStyle.SANS_SERIF.value)
|
||||||
from django.utils.timezone import is_naive, make_aware
|
background_color = models.CharField(max_length=7, choices=BackgroundColor.choices, default=BackgroundColor.WHITE.value)
|
||||||
from datetime import datetime
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
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
|
class Meta:
|
||||||
self.last_page = last_page
|
db_table = "books_reading_settings"
|
||||||
self.device_id = device_id
|
verbose_name_plural = "reading settings"
|
||||||
self.device_name = device_name
|
|
||||||
self.version += 1
|
def __str__(self):
|
||||||
self.save(update_fields=[
|
return f"Settings for {self.user}"
|
||||||
"current_position", "last_page",
|
|
||||||
"device_id", "device_name", "version", "updated_at",
|
|
||||||
])
|
|
||||||
return self, True
|
|
||||||
@@ -1,46 +1,6 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
import logging
|
from apps.books.models import Book, EBook, FontStyle, BackgroundColor, ReadingProgress, ReadingSettings, ReadingStatus
|
||||||
|
|
||||||
from apps.books.models import Book, BookChapter, EBook, ReadingProgress, ReadingStatus, DownloadRecord
|
|
||||||
from apps.books.services.ebook_metadata import subjects_from_ebook
|
|
||||||
from apps.books.services.metadata import enrich_ebook_metadata
|
|
||||||
from apps.books.services.process_ebook import apply_processing_to_ebook
|
|
||||||
from apps.reader.models import ReadingSettings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
class BookReadingSettingsSerializer(serializers.ModelSerializer):
|
|
||||||
font_style = serializers.CharField(source="font_family")
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = ReadingSettings
|
|
||||||
fields = ["font_size", "font_style", "background_color"]
|
|
||||||
|
|
||||||
def validate_font_size(self, value: int) -> int:
|
|
||||||
if value < 12 or value > 36:
|
|
||||||
raise serializers.ValidationError("Font size must be between 12 and 36.")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class BookChapterSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = BookChapter
|
|
||||||
fields = ["id", "title", "index", "href", "children"]
|
|
||||||
|
|
||||||
|
|
||||||
class EBookContentSerializer(serializers.Serializer):
|
|
||||||
page = serializers.IntegerField()
|
|
||||||
total_pages = serializers.IntegerField()
|
|
||||||
content = serializers.CharField()
|
|
||||||
chapter_title = serializers.CharField()
|
|
||||||
format = serializers.CharField()
|
|
||||||
|
|
||||||
|
|
||||||
class EBookTocSerializer(serializers.Serializer):
|
|
||||||
chapters = serializers.ListField(child=BookChapterSerializer())
|
|
||||||
format = serializers.CharField()
|
|
||||||
page_count = serializers.IntegerField()
|
|
||||||
|
|
||||||
|
|
||||||
class BookListSerializer(serializers.ModelSerializer):
|
class BookListSerializer(serializers.ModelSerializer):
|
||||||
@@ -69,20 +29,11 @@ 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 = [
|
fields = ["id", "title", "author", "filename", "cover_image", "created_at", "progress"]
|
||||||
"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:
|
||||||
@@ -90,32 +41,15 @@ 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 = [
|
fields = ["id", "title", "author", "filename", "file_url", "cover_image", "created_at", "updated_at", "progress"]
|
||||||
"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")
|
||||||
@@ -126,11 +60,7 @@ 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 {
|
return {"current_position": rp.current_position, "last_page": rp.last_page}
|
||||||
"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
|
||||||
|
|
||||||
@@ -151,35 +81,15 @@ 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
|
||||||
name = str(getattr(validated_data.get("file"), "name", ""))
|
return super().create(validated_data)
|
||||||
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 = [
|
fields = ["current_position", "last_page"]
|
||||||
"current_position", "last_page", "epub_location",
|
extra_kwargs = {"current_position": {"required": True, "min_value": 0.0, "max_value": 100.0}}
|
||||||
"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:
|
||||||
@@ -187,45 +97,24 @@ class ReadingProgressSerializer(serializers.ModelSerializer):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class DownloadRecordSerializer(serializers.ModelSerializer):
|
class ReadingSettingsSerializer(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 = DownloadRecord
|
model = ReadingSettings
|
||||||
fields = [
|
fields = ["font_size", "font_style", "background_color"]
|
||||||
"id", "ebook_id", "ebook_title", "author", "filename", "file_url",
|
|
||||||
"file_size", "cover_image", "format", "downloaded_at", "progress",
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_filename(self, obj):
|
def validate_font_size(self, value):
|
||||||
return obj.ebook.filename()
|
if value < 12 or value > 36:
|
||||||
|
raise serializers.ValidationError("Font size must be between 12 and 36.")
|
||||||
|
return value
|
||||||
|
|
||||||
def get_file_url(self, obj):
|
def validate_font_style(self, value):
|
||||||
request = self.context.get("request")
|
valid = [s.value for s in FontStyle]
|
||||||
if request and obj.ebook.file:
|
if value not in valid:
|
||||||
return request.build_absolute_uri(obj.ebook.file.url)
|
raise serializers.ValidationError(f"Font style must be one of: {', '.join(valid)}")
|
||||||
return ""
|
return value
|
||||||
|
|
||||||
def get_progress(self, obj):
|
def validate_background_color(self, value):
|
||||||
try:
|
valid = [c.value for c in BackgroundColor]
|
||||||
rp = obj.ebook.reading_progress
|
if value not in valid:
|
||||||
return {
|
raise serializers.ValidationError(f"Background color must be one of: {', '.join(valid)}")
|
||||||
"current_position": rp.current_position,
|
return value
|
||||||
"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())
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
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()]
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
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),
|
|
||||||
)
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
"""Tests for the Book search & discovery endpoints."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from django.urls import reverse
|
|
||||||
from rest_framework import status
|
|
||||||
from rest_framework.test import APIClient
|
|
||||||
|
|
||||||
from apps.books.models import Book, ReadingStatus
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Fixtures
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def api_client():
|
|
||||||
return APIClient()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def user(django_user_model):
|
|
||||||
return django_user_model.objects.create_user(
|
|
||||||
email="reader@example.com",
|
|
||||||
password="testpass123",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def auth_client(api_client, user):
|
|
||||||
api_client.force_authenticate(user=user)
|
|
||||||
return api_client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def books():
|
|
||||||
books_data = [
|
|
||||||
Book.objects.create(
|
|
||||||
title="Dune",
|
|
||||||
author="Frank Herbert",
|
|
||||||
genre="Science Fiction",
|
|
||||||
reading_status=ReadingStatus.FINISHED,
|
|
||||||
total_pages=688,
|
|
||||||
description="A desert planet saga.",
|
|
||||||
),
|
|
||||||
Book.objects.create(
|
|
||||||
title="Neuromancer",
|
|
||||||
author="William Gibson",
|
|
||||||
genre="Science Fiction",
|
|
||||||
reading_status=ReadingStatus.READING,
|
|
||||||
total_pages=271,
|
|
||||||
description="Cyberpunk classic.",
|
|
||||||
),
|
|
||||||
Book.objects.create(
|
|
||||||
title="The Hobbit",
|
|
||||||
author="J.R.R. Tolkien",
|
|
||||||
genre="Fantasy",
|
|
||||||
reading_status=ReadingStatus.WANT_TO_READ,
|
|
||||||
total_pages=310,
|
|
||||||
description="A hobbit's adventure.",
|
|
||||||
),
|
|
||||||
Book.objects.create(
|
|
||||||
title="1984",
|
|
||||||
author="George Orwell",
|
|
||||||
genre="Dystopian",
|
|
||||||
reading_status=ReadingStatus.FINISHED,
|
|
||||||
total_pages=328,
|
|
||||||
description="Big Brother is watching.",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
return books_data
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Search tests
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestBookSearch:
|
|
||||||
"""Verify the search endpoint returns correct results."""
|
|
||||||
|
|
||||||
def test_search_by_title(self, auth_client, books):
|
|
||||||
url = reverse("book-list")
|
|
||||||
response = auth_client.get(url, {"q": "Dune"})
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
titles = [b["title"] for b in response.data["results"]]
|
|
||||||
assert "Dune" in titles
|
|
||||||
assert "Neuromancer" not in titles
|
|
||||||
|
|
||||||
def test_search_by_author(self, auth_client, books):
|
|
||||||
url = reverse("book-list")
|
|
||||||
response = auth_client.get(url, {"q": "Tolkien"})
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
titles = [b["title"] for b in response.data["results"]]
|
|
||||||
assert "The Hobbit" in titles
|
|
||||||
|
|
||||||
def test_search_by_genre(self, auth_client, books):
|
|
||||||
url = reverse("book-list")
|
|
||||||
response = auth_client.get(url, {"q": "Fantasy"})
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
titles = [b["title"] for b in response.data["results"]]
|
|
||||||
assert "The Hobbit" in titles
|
|
||||||
|
|
||||||
def test_search_case_insensitive(self, auth_client, books):
|
|
||||||
url = reverse("book-list")
|
|
||||||
response = auth_client.get(url, {"q": "dune"})
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
assert any(b["title"] == "Dune" for b in response.data["results"])
|
|
||||||
|
|
||||||
def test_search_partial_match(self, auth_client, books):
|
|
||||||
url = reverse("book-list")
|
|
||||||
response = auth_client.get(url, {"q": "Neu"})
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
titles = [b["title"] for b in response.data["results"]]
|
|
||||||
assert "Neuromancer" in titles
|
|
||||||
|
|
||||||
def test_search_empty_query_returns_all(self, auth_client, books):
|
|
||||||
url = reverse("book-list")
|
|
||||||
response = auth_client.get(url, {"q": ""})
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
assert len(response.data["results"]) == 4
|
|
||||||
|
|
||||||
def test_search_no_results(self, auth_client, books):
|
|
||||||
url = reverse("book-list")
|
|
||||||
response = auth_client.get(url, {"q": "zzzznotfound"})
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
assert len(response.data["results"]) == 0
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Filter tests
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestBookFilters:
|
|
||||||
"""Verify filters for genre, author, and reading_status."""
|
|
||||||
|
|
||||||
def test_filter_by_genre(self, auth_client, books):
|
|
||||||
url = reverse("book-list")
|
|
||||||
response = auth_client.get(url, {"genre": "Science Fiction"})
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
titles = [b["title"] for b in response.data["results"]]
|
|
||||||
assert "Dune" in titles
|
|
||||||
assert "Neuromancer" in titles
|
|
||||||
assert "The Hobbit" not in titles
|
|
||||||
|
|
||||||
def test_filter_by_author(self, auth_client, books):
|
|
||||||
url = reverse("book-list")
|
|
||||||
response = auth_client.get(url, {"author": "George Orwell"})
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
titles = [b["title"] for b in response.data["results"]]
|
|
||||||
assert "1984" in titles
|
|
||||||
assert "Dune" not in titles
|
|
||||||
|
|
||||||
def test_filter_by_reading_status(self, auth_client, books):
|
|
||||||
url = reverse("book-list")
|
|
||||||
response = auth_client.get(url, {"reading_status": ReadingStatus.FINISHED})
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
titles = [b["title"] for b in response.data["results"]]
|
|
||||||
assert "Dune" in titles
|
|
||||||
assert "1984" in titles
|
|
||||||
assert "Neuromancer" not in titles
|
|
||||||
assert "The Hobbit" not in titles
|
|
||||||
|
|
||||||
def test_filter_combined_with_search(self, auth_client, books):
|
|
||||||
"""Search + filter should intersect results."""
|
|
||||||
url = reverse("book-list")
|
|
||||||
response = auth_client.get(url, {"q": "Dune", "reading_status": ReadingStatus.FINISHED})
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
titles = [b["title"] for b in response.data["results"]]
|
|
||||||
assert "Dune" in titles
|
|
||||||
# 1984 matches reading_status but not search
|
|
||||||
assert "1984" not in titles
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Discovery endpoints
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestBookDiscovery:
|
|
||||||
"""Verify genre and author discovery endpoints."""
|
|
||||||
|
|
||||||
def test_genres_endpoint(self, auth_client, books):
|
|
||||||
url = reverse("book-genres")
|
|
||||||
response = auth_client.get(url)
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
assert isinstance(response.data, list)
|
|
||||||
assert "Science Fiction" in response.data
|
|
||||||
assert "Fantasy" in response.data
|
|
||||||
assert "Dystopian" in response.data
|
|
||||||
# No duplicate genres
|
|
||||||
assert response.data.count("Science Fiction") == 1
|
|
||||||
|
|
||||||
def test_authors_endpoint(self, auth_client, books):
|
|
||||||
url = reverse("book-authors")
|
|
||||||
response = auth_client.get(url)
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
assert isinstance(response.data, list)
|
|
||||||
assert "Frank Herbert" in response.data
|
|
||||||
assert "J.R.R. Tolkien" in response.data
|
|
||||||
|
|
||||||
def test_genres_requires_auth(self, api_client, books):
|
|
||||||
url = reverse("book-genres")
|
|
||||||
response = api_client.get(url)
|
|
||||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
|
||||||
|
|
||||||
def test_authors_requires_auth(self, api_client, books):
|
|
||||||
url = reverse("book-authors")
|
|
||||||
response = api_client.get(url)
|
|
||||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Detail view
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestBookDetail:
|
|
||||||
"""Verify the book detail endpoint."""
|
|
||||||
|
|
||||||
def test_retrieve_book(self, auth_client, books):
|
|
||||||
book = books[0]
|
|
||||||
url = reverse("book-detail", kwargs={"pk": book.pk})
|
|
||||||
response = auth_client.get(url)
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
assert response.data["title"] == "Dune"
|
|
||||||
assert response.data["author"] == "Frank Herbert"
|
|
||||||
assert response.data["description"] == "A desert planet saga."
|
|
||||||
assert response.data["total_pages"] == 688
|
|
||||||
|
|
||||||
def test_retrieve_nonexistent_returns_404(self, auth_client, books):
|
|
||||||
url = reverse("book-detail", kwargs={"pk": 99999})
|
|
||||||
response = auth_client.get(url)
|
|
||||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
|
||||||
|
|
||||||
def test_pagination(self, auth_client, books):
|
|
||||||
"""List with small page size should paginate."""
|
|
||||||
url = reverse("book-list")
|
|
||||||
response = auth_client.get(f"{url}?page_size=2")
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
assert "count" in response.data
|
|
||||||
assert "results" in response.data
|
|
||||||
assert response.data["count"] == 4
|
|
||||||
|
|
||||||
def test_ordering(self, auth_client, books):
|
|
||||||
url = reverse("book-list")
|
|
||||||
response = auth_client.get(url, {"ordering": "title"})
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
titles = [b["title"] for b in response.data["results"]]
|
|
||||||
assert titles == sorted(titles)
|
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
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, book_reading_settings_view
|
from apps.books.views import BookViewSet, EBookViewSet, ReadingSettingsViewSet
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r"ebooks", EBookViewSet, basename="ebook")
|
|
||||||
router.register(r"", BookViewSet, basename="book")
|
router.register(r"", BookViewSet, basename="book")
|
||||||
|
|
||||||
|
ebook_router = DefaultRouter()
|
||||||
|
ebook_router.register(r"ebooks", EBookViewSet, basename="ebook")
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("settings/", book_reading_settings_view, name="book-settings"),
|
|
||||||
path("", include(router.urls)),
|
path("", include(router.urls)),
|
||||||
|
path("", include(ebook_router.urls)),
|
||||||
|
path("settings/", ReadingSettingsViewSet.as_view({"get": "list", "patch": "partial_update"}), name="reading-settings"),
|
||||||
]
|
]
|
||||||
+22
-245
@@ -1,30 +1,20 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
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, api_view, permission_classes
|
from rest_framework.decorators import action
|
||||||
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, DownloadRecord, EBook, ReadingProgress
|
from apps.books.models import Book, EBook, ReadingProgress, ReadingSettings
|
||||||
from apps.books.serializers import (
|
from apps.books.serializers import (
|
||||||
BookChapterSerializer, BookDetailSerializer, BookListSerializer, BookReadingSettingsSerializer,
|
BookDetailSerializer, BookListSerializer, BookSerializer,
|
||||||
BookSerializer, DownloadRecordSerializer, EBookContentSerializer, EBookDetailSerializer,
|
EBookDetailSerializer, EBookListSerializer, EBookUploadSerializer,
|
||||||
EBookListSerializer, EBookTocSerializer, EBookUploadSerializer,
|
ReadingProgressSerializer, ReadingSettingsSerializer,
|
||||||
ReadingProgressSerializer, StorageSummarySerializer,
|
|
||||||
)
|
)
|
||||||
from apps.reader.models import ReadingSettings
|
|
||||||
from apps.books.services.ebook_metadata import subjects_from_ebook
|
|
||||||
from apps.books.services.metadata import enrich_ebook_metadata
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class BookViewSet(viewsets.ModelViewSet):
|
class BookViewSet(viewsets.ModelViewSet):
|
||||||
@@ -60,23 +50,6 @@ 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:
|
||||||
@@ -90,37 +63,12 @@ class EBookViewSet(viewsets.ModelViewSet):
|
|||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
if self.action == "create":
|
if self.action == "create":
|
||||||
return EBookUploadSerializer
|
return EBookUploadSerializer
|
||||||
if self.action in ("list",):
|
if self.action == "list":
|
||||||
return EBookListSerializer
|
return EBookListSerializer
|
||||||
if self.action in ("toc",):
|
|
||||||
return BookChapterSerializer
|
|
||||||
return EBookDetailSerializer
|
return EBookDetailSerializer
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = EBook.objects.filter(user=self.request.user).select_related("reading_progress", "user")
|
return 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:
|
||||||
@@ -134,193 +82,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:
|
class ReadingSettingsViewSet(viewsets.GenericViewSet):
|
||||||
"""Re-fetch Open Library metadata and cover for this ebook."""
|
permission_classes = [IsAuthenticated]
|
||||||
ebook = self.get_object()
|
serializer_class = ReadingSettingsSerializer
|
||||||
try:
|
|
||||||
enrich_ebook_metadata(ebook)
|
def get_queryset(self):
|
||||||
except Exception:
|
return ReadingSettings.objects.filter(user=self.request.user)
|
||||||
logger.exception("Manual metadata enrichment failed for ebook %s", ebook.id)
|
|
||||||
return Response(
|
def list(self, request: Request) -> Response:
|
||||||
{"error": "Metadata enrichment failed."},
|
settings_obj, _created = ReadingSettings.objects.get_or_create(user=request.user)
|
||||||
status=status.HTTP_502_BAD_GATEWAY,
|
serializer = self.get_serializer(settings_obj)
|
||||||
)
|
|
||||||
ebook.refresh_from_db()
|
|
||||||
serializer = EBookDetailSerializer(ebook, context={"request": request})
|
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
@action(detail=True, methods=["post"])
|
def partial_update(self, request: Request) -> Response:
|
||||||
def process(self, request: Request, pk: int | None = None) -> Response:
|
settings_obj, _created = ReadingSettings.objects.get_or_create(user=request.user)
|
||||||
"""Trigger e-book processing: metadata extraction, TOC building, page counting."""
|
serializer = self.get_serializer(settings_obj, data=request.data, partial=True)
|
||||||
ebook = self.get_object()
|
|
||||||
if not ebook.file:
|
|
||||||
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
try:
|
|
||||||
from apps.books.services.process_ebook import apply_processing_to_ebook
|
|
||||||
|
|
||||||
result = apply_processing_to_ebook(ebook)
|
|
||||||
return Response(result)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("Failed to process ebook %s", ebook.id)
|
|
||||||
return Response({"error": f"Processing failed: {exc}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
||||||
|
|
||||||
@action(detail=True, methods=["get"])
|
|
||||||
def file(self, request: Request, pk: int | None = None) -> FileResponse | Response:
|
|
||||||
"""Stream the ebook file for authenticated in-browser reading."""
|
|
||||||
ebook = self.get_object()
|
|
||||||
content_types = {
|
|
||||||
"epub": "application/epub+zip",
|
|
||||||
"pdf": "application/pdf",
|
|
||||||
}
|
|
||||||
content_type = content_types.get(ebook.format)
|
|
||||||
if not content_type:
|
|
||||||
return Response(
|
|
||||||
{"error": "Unsupported format for in-browser reading."},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
if not ebook.file:
|
|
||||||
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
return FileResponse(
|
|
||||||
ebook.file.open("rb"),
|
|
||||||
content_type=content_type,
|
|
||||||
filename=ebook.filename(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@action(detail=True, methods=["get"])
|
|
||||||
def toc(self, request: Request, pk: int | None = None) -> Response:
|
|
||||||
"""Return hierarchical table of contents."""
|
|
||||||
ebook = self.get_object()
|
|
||||||
chapters = BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook")
|
|
||||||
serializer = BookChapterSerializer(chapters, many=True)
|
|
||||||
return Response({
|
|
||||||
"chapters": serializer.data,
|
|
||||||
"format": ebook.format,
|
|
||||||
"page_count": ebook.page_count,
|
|
||||||
})
|
|
||||||
|
|
||||||
@action(detail=True, methods=["get"])
|
|
||||||
def content(self, request: Request, pk: int | None = None) -> Response:
|
|
||||||
"""Return paginated content for a given page number.
|
|
||||||
|
|
||||||
Query params:
|
|
||||||
page (int): page/chapter index to fetch (1-indexed, default: 1)
|
|
||||||
"""
|
|
||||||
ebook = self.get_object()
|
|
||||||
page = max(1, int(request.query_params.get("page", 1)))
|
|
||||||
|
|
||||||
chapters = list(BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook"))
|
|
||||||
total_pages = len(chapters) or ebook.page_count or 1
|
|
||||||
|
|
||||||
chapter: BookChapter | None = None
|
|
||||||
chapter_title = ""
|
|
||||||
content_html = ""
|
|
||||||
|
|
||||||
if chapters and 0 <= page - 1 < len(chapters):
|
|
||||||
ch = chapters[page - 1]
|
|
||||||
chapter_title = ch.title
|
|
||||||
content_html = _fetch_chapter_content(ebook, ch)
|
|
||||||
|
|
||||||
serializer = EBookContentSerializer(data={
|
|
||||||
"page": page,
|
|
||||||
"total_pages": total_pages,
|
|
||||||
"content": content_html,
|
|
||||||
"chapter_title": chapter_title,
|
|
||||||
"format": ebook.format,
|
|
||||||
})
|
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
serializer.save()
|
||||||
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)
|
|
||||||
|
|
||||||
download, created = DownloadRecord.objects.get_or_create(
|
|
||||||
user=request.user,
|
|
||||||
ebook=ebook,
|
|
||||||
defaults={"file_size": ebook.file.size if ebook.file else 0},
|
|
||||||
)
|
|
||||||
if not created:
|
|
||||||
download.file_size = ebook.file.size if ebook.file else 0
|
|
||||||
download.save(update_fields=["file_size"])
|
|
||||||
|
|
||||||
serializer = DownloadRecordSerializer(download, context={"request": request})
|
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"])
|
|
||||||
def downloads(self, request: Request) -> Response:
|
|
||||||
"""List all e-books the current user has downloaded."""
|
|
||||||
records = DownloadRecord.objects.filter(user=request.user).select_related(
|
|
||||||
"ebook", "ebook__reading_progress"
|
|
||||||
).prefetch_related("ebook__chapters")
|
|
||||||
page = self.paginate_queryset(records)
|
|
||||||
if page is not None:
|
|
||||||
serializer = DownloadRecordSerializer(page, many=True, context={"request": request})
|
|
||||||
return self.get_paginated_response(serializer.data)
|
|
||||||
serializer = DownloadRecordSerializer(records, many=True, context={"request": request})
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["delete"], url_path="downloads/(?P<download_pk>[^/.]+)")
|
|
||||||
def delete_download(self, request: Request, download_pk: str | None = None) -> Response:
|
|
||||||
"""Delete a download record."""
|
|
||||||
try:
|
|
||||||
download = DownloadRecord.objects.get(pk=download_pk, user=request.user)
|
|
||||||
except DownloadRecord.DoesNotExist:
|
|
||||||
return Response({"error": "Download record not found."}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
download.delete()
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_chapter_content(ebook: EBook, chapter: BookChapter) -> str:
|
|
||||||
"""Fetch HTML content for a chapter from the e-book file."""
|
|
||||||
if ebook.format == "epub":
|
|
||||||
return _fetch_epub_chapter_content(ebook.file.path, chapter)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_epub_chapter_content(file_path: str, chapter: BookChapter) -> str:
|
|
||||||
"""Extract HTML content of a specific EPUB chapter by href."""
|
|
||||||
try:
|
|
||||||
from ebooklib import epub
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
except ImportError:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
try:
|
|
||||||
book = epub.read_epub(file_path)
|
|
||||||
href = chapter.href or ""
|
|
||||||
# Find the item by href
|
|
||||||
for item in book.get_items():
|
|
||||||
item_name = item.get_name() or ""
|
|
||||||
if href and (item_name.endswith(href) or href.endswith(item_name)):
|
|
||||||
content = item.get_content()
|
|
||||||
soup = BeautifulSoup(content, "html.parser")
|
|
||||||
# Clean up — remove body/html/head wrappers, keep inner content
|
|
||||||
body = soup.find("body")
|
|
||||||
if body:
|
|
||||||
return str(body)
|
|
||||||
return str(soup)
|
|
||||||
return ""
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to fetch EPUB chapter content for %s", chapter.href)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET", "PATCH"])
|
|
||||||
@permission_classes([IsAuthenticated])
|
|
||||||
def book_reading_settings_view(request: Request) -> Response:
|
|
||||||
"""Get or update reading settings via the books API contract."""
|
|
||||||
settings, _created = ReadingSettings.objects.get_or_create(user=request.user)
|
|
||||||
|
|
||||||
if request.method == "GET":
|
|
||||||
serializer = BookReadingSettingsSerializer(settings)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
serializer = BookReadingSettingsSerializer(settings, data=request.data, partial=True)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
serializer.save()
|
|
||||||
return Response(serializer.data)
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
from apps.groups.models import Group, GroupInvite, GroupMember, JoinRequest
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Group)
|
|
||||||
class GroupAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ["id", "name", "created_by", "created_at"]
|
|
||||||
search_fields = ["name", "created_by__email"]
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(GroupMember)
|
|
||||||
class GroupMemberAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ["id", "group", "user", "role", "joined_at"]
|
|
||||||
list_filter = ["role"]
|
|
||||||
search_fields = ["group__name", "user__email"]
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(GroupInvite)
|
|
||||||
class GroupInviteAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ["id", "group", "code", "created_by", "use_count", "is_active", "created_at"]
|
|
||||||
list_filter = ["is_active"]
|
|
||||||
search_fields = ["code", "group__name"]
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(JoinRequest)
|
|
||||||
class JoinRequestAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ["id", "group", "user", "status", "created_at"]
|
|
||||||
list_filter = ["status"]
|
|
||||||
search_fields = ["group__name", "user__email"]
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class GroupsConfig(AppConfig):
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
|
||||||
name = "apps.groups"
|
|
||||||
verbose_name = "Groups"
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
# Generated by Django 5.x for groups app
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="Group",
|
|
||||||
fields=[
|
|
||||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
||||||
("name", models.CharField(db_index=True, max_length=256)),
|
|
||||||
("description", models.TextField(blank=True, default="")),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("updated_at", models.DateTimeField(auto_now=True)),
|
|
||||||
("created_by", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="created_groups", to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"db_table": "groups_group",
|
|
||||||
"verbose_name": "Group",
|
|
||||||
"verbose_name_plural": "Groups",
|
|
||||||
"ordering": ["-created_at"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="GroupMember",
|
|
||||||
fields=[
|
|
||||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
||||||
("role", models.CharField(choices=[("admin", "Admin"), ("member", "Member")], default="member", max_length=16)),
|
|
||||||
("joined_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("group", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="memberships", to="groups.group")),
|
|
||||||
("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="group_memberships", to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"db_table": "groups_member",
|
|
||||||
"verbose_name": "Group Member",
|
|
||||||
"verbose_name_plural": "Group Members",
|
|
||||||
"ordering": ["joined_at"],
|
|
||||||
"unique_together": {("group", "user")},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="GroupInvite",
|
|
||||||
fields=[
|
|
||||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
||||||
("code", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
|
|
||||||
("max_uses", models.PositiveIntegerField(default=0, help_text="0 = unlimited")),
|
|
||||||
("use_count", models.PositiveIntegerField(default=0)),
|
|
||||||
("is_active", models.BooleanField(db_index=True, default=True)),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("created_by", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="created_invites", to=settings.AUTH_USER_MODEL)),
|
|
||||||
("group", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="invites", to="groups.group")),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"db_table": "groups_invite",
|
|
||||||
"verbose_name": "Group Invite",
|
|
||||||
"verbose_name_plural": "Group Invites",
|
|
||||||
"ordering": ["-created_at"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="JoinRequest",
|
|
||||||
fields=[
|
|
||||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
||||||
("status", models.CharField(choices=[("pending", "Pending"), ("approved", "Approved"), ("rejected", "Rejected")], db_index=True, default="pending", max_length=16)),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("group", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="join_requests", to="groups.group")),
|
|
||||||
("invite", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="join_requests", to="groups.groupinvite")),
|
|
||||||
("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="join_requests", to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"db_table": "groups_join_request",
|
|
||||||
"verbose_name": "Join Request",
|
|
||||||
"verbose_name_plural": "Join Requests",
|
|
||||||
"ordering": ["-created_at"],
|
|
||||||
"unique_together": {("group", "user")},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import uuid
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
|
|
||||||
class GroupRole(models.TextChoices):
|
|
||||||
ADMIN = "admin", "Admin"
|
|
||||||
MEMBER = "member", "Member"
|
|
||||||
|
|
||||||
|
|
||||||
class JoinRequestStatus(models.TextChoices):
|
|
||||||
PENDING = "pending", "Pending"
|
|
||||||
APPROVED = "approved", "Approved"
|
|
||||||
REJECTED = "rejected", "Rejected"
|
|
||||||
|
|
||||||
|
|
||||||
class Group(models.Model):
|
|
||||||
name = models.CharField(max_length=256, db_index=True)
|
|
||||||
description = models.TextField(blank=True, default="")
|
|
||||||
created_by = models.ForeignKey(
|
|
||||||
settings.AUTH_USER_MODEL,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="created_groups",
|
|
||||||
)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = "groups_group"
|
|
||||||
verbose_name = "Group"
|
|
||||||
verbose_name_plural = "Groups"
|
|
||||||
ordering = ["-created_at"]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
|
|
||||||
class GroupMember(models.Model):
|
|
||||||
group = models.ForeignKey(
|
|
||||||
Group,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="memberships",
|
|
||||||
)
|
|
||||||
user = models.ForeignKey(
|
|
||||||
settings.AUTH_USER_MODEL,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="group_memberships",
|
|
||||||
)
|
|
||||||
role = models.CharField(
|
|
||||||
max_length=16,
|
|
||||||
choices=GroupRole.choices,
|
|
||||||
default=GroupRole.MEMBER,
|
|
||||||
)
|
|
||||||
joined_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = "groups_member"
|
|
||||||
verbose_name = "Group Member"
|
|
||||||
verbose_name_plural = "Group Members"
|
|
||||||
ordering = ["joined_at"]
|
|
||||||
unique_together = [("group", "user")]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"{self.user} in {self.group} ({self.role})"
|
|
||||||
|
|
||||||
|
|
||||||
class GroupInvite(models.Model):
|
|
||||||
group = models.ForeignKey(
|
|
||||||
Group,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="invites",
|
|
||||||
)
|
|
||||||
created_by = models.ForeignKey(
|
|
||||||
settings.AUTH_USER_MODEL,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="created_invites",
|
|
||||||
)
|
|
||||||
code = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
|
|
||||||
max_uses = models.PositiveIntegerField(default=0, help_text="0 = unlimited")
|
|
||||||
use_count = models.PositiveIntegerField(default=0)
|
|
||||||
is_active = models.BooleanField(default=True, db_index=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = "groups_invite"
|
|
||||||
verbose_name = "Group Invite"
|
|
||||||
verbose_name_plural = "Group Invites"
|
|
||||||
ordering = ["-created_at"]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"Invite for {self.group.name} ({self.code})"
|
|
||||||
|
|
||||||
|
|
||||||
class JoinRequest(models.Model):
|
|
||||||
group = models.ForeignKey(
|
|
||||||
Group,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="join_requests",
|
|
||||||
)
|
|
||||||
user = models.ForeignKey(
|
|
||||||
settings.AUTH_USER_MODEL,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="join_requests",
|
|
||||||
)
|
|
||||||
invite = models.ForeignKey(
|
|
||||||
GroupInvite,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name="join_requests",
|
|
||||||
)
|
|
||||||
status = models.CharField(
|
|
||||||
max_length=16,
|
|
||||||
choices=JoinRequestStatus.choices,
|
|
||||||
default=JoinRequestStatus.PENDING,
|
|
||||||
db_index=True,
|
|
||||||
)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = "groups_join_request"
|
|
||||||
verbose_name = "Join Request"
|
|
||||||
verbose_name_plural = "Join Requests"
|
|
||||||
ordering = ["-created_at"]
|
|
||||||
unique_together = [("group", "user")]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"{self.user} → {self.group.name} ({self.status})"
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
from rest_framework import permissions
|
|
||||||
from rest_framework.request import Request
|
|
||||||
|
|
||||||
from apps.groups.models import Group, GroupMember, GroupRole
|
|
||||||
|
|
||||||
|
|
||||||
class IsGroupAdmin(permissions.BasePermission):
|
|
||||||
"""Only group admins can perform the action."""
|
|
||||||
|
|
||||||
def has_object_permission(self, request: Request, view: object, obj: Group) -> bool:
|
|
||||||
return GroupMember.objects.filter(
|
|
||||||
group=obj,
|
|
||||||
user=request.user,
|
|
||||||
role=GroupRole.ADMIN,
|
|
||||||
).exists()
|
|
||||||
|
|
||||||
|
|
||||||
class IsGroupMember(permissions.BasePermission):
|
|
||||||
"""Only group members (any role) can perform the action."""
|
|
||||||
|
|
||||||
def has_object_permission(self, request: Request, view: object, obj: Group) -> bool:
|
|
||||||
return GroupMember.objects.filter(
|
|
||||||
group=obj,
|
|
||||||
user=request.user,
|
|
||||||
).exists()
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
from apps.groups.models import Group, GroupInvite, GroupMember, GroupRole, JoinRequest, JoinRequestStatus
|
|
||||||
|
|
||||||
|
|
||||||
class GroupMemberSerializer(serializers.ModelSerializer):
|
|
||||||
user_id = serializers.IntegerField(source="user.id", read_only=True)
|
|
||||||
user_email = serializers.CharField(source="user.email", read_only=True)
|
|
||||||
user_username = serializers.CharField(source="user.username", read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = GroupMember
|
|
||||||
fields = [
|
|
||||||
"id", "user_id", "user_email", "user_username",
|
|
||||||
"role", "joined_at",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class GroupListSerializer(serializers.ModelSerializer):
|
|
||||||
member_count = serializers.SerializerMethodField()
|
|
||||||
user_role = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Group
|
|
||||||
fields = [
|
|
||||||
"id", "name", "description", "created_by",
|
|
||||||
"member_count", "user_role", "created_at", "updated_at",
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_member_count(self, obj: Group) -> int:
|
|
||||||
return getattr(obj, "_member_count", obj.memberships.count())
|
|
||||||
|
|
||||||
def get_user_role(self, obj: Group) -> str | None:
|
|
||||||
request = self.context.get("request")
|
|
||||||
if not request or not request.user.is_authenticated:
|
|
||||||
return None
|
|
||||||
membership = getattr(obj, "_user_membership", None)
|
|
||||||
if membership is None:
|
|
||||||
try:
|
|
||||||
membership = obj.memberships.get(user=request.user)
|
|
||||||
except GroupMember.DoesNotExist:
|
|
||||||
return None
|
|
||||||
return membership.role
|
|
||||||
|
|
||||||
|
|
||||||
class GroupDetailSerializer(serializers.ModelSerializer):
|
|
||||||
members = GroupMemberSerializer(source="memberships", many=True, read_only=True)
|
|
||||||
member_count = serializers.SerializerMethodField()
|
|
||||||
user_role = serializers.SerializerMethodField()
|
|
||||||
created_by_email = serializers.CharField(source="created_by.email", read_only=True)
|
|
||||||
created_by_username = serializers.CharField(source="created_by.username", read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Group
|
|
||||||
fields = [
|
|
||||||
"id", "name", "description", "created_by", "created_by_email",
|
|
||||||
"created_by_username", "members", "member_count", "user_role",
|
|
||||||
"created_at", "updated_at",
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_member_count(self, obj: Group) -> int:
|
|
||||||
return getattr(obj, "_member_count", obj.memberships.count())
|
|
||||||
|
|
||||||
def get_user_role(self, obj: Group) -> str | None:
|
|
||||||
request = self.context.get("request")
|
|
||||||
if not request or not request.user.is_authenticated:
|
|
||||||
return None
|
|
||||||
membership = getattr(obj, "_user_membership", None)
|
|
||||||
if membership is None:
|
|
||||||
try:
|
|
||||||
membership = obj.memberships.get(user=request.user)
|
|
||||||
except GroupMember.DoesNotExist:
|
|
||||||
return None
|
|
||||||
return membership.role
|
|
||||||
|
|
||||||
|
|
||||||
class GroupCreateSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = Group
|
|
||||||
fields = ["name", "description"]
|
|
||||||
|
|
||||||
def validate_name(self, value: str) -> str:
|
|
||||||
if not value.strip():
|
|
||||||
raise serializers.ValidationError("Group name cannot be empty.")
|
|
||||||
if len(value.strip()) < 2:
|
|
||||||
raise serializers.ValidationError("Group name must be at least 2 characters.")
|
|
||||||
return value.strip()
|
|
||||||
|
|
||||||
def create(self, validated_data: dict) -> Group:
|
|
||||||
user = self.context["request"].user
|
|
||||||
group = Group.objects.create(created_by=user, **validated_data)
|
|
||||||
GroupMember.objects.create(group=group, user=user, role=GroupRole.ADMIN)
|
|
||||||
return group
|
|
||||||
|
|
||||||
|
|
||||||
class GroupUpdateSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = Group
|
|
||||||
fields = ["name", "description"]
|
|
||||||
|
|
||||||
def validate_name(self, value: str) -> str:
|
|
||||||
if not value.strip():
|
|
||||||
raise serializers.ValidationError("Group name cannot be empty.")
|
|
||||||
if len(value.strip()) < 2:
|
|
||||||
raise serializers.ValidationError("Group name must be at least 2 characters.")
|
|
||||||
return value.strip()
|
|
||||||
|
|
||||||
|
|
||||||
class GroupInviteSerializer(serializers.ModelSerializer):
|
|
||||||
created_by_email = serializers.CharField(source="created_by.email", read_only=True)
|
|
||||||
group_name = serializers.CharField(source="group.name", read_only=True)
|
|
||||||
join_url = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = GroupInvite
|
|
||||||
fields = [
|
|
||||||
"id", "group", "group_name", "code", "created_by", "created_by_email",
|
|
||||||
"max_uses", "use_count", "is_active", "join_url", "created_at",
|
|
||||||
]
|
|
||||||
read_only_fields = ["id", "group", "code", "created_by", "use_count", "created_at"]
|
|
||||||
|
|
||||||
def get_join_url(self, obj: GroupInvite) -> str:
|
|
||||||
request = self.context.get("request")
|
|
||||||
if request:
|
|
||||||
return f"{request.build_absolute_uri('/')[:-1]}/groups/join/{obj.code}"
|
|
||||||
return f"/groups/join/{obj.code}"
|
|
||||||
|
|
||||||
|
|
||||||
class GroupInviteCreateSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = GroupInvite
|
|
||||||
fields = ["max_uses"]
|
|
||||||
|
|
||||||
def validate_max_uses(self, value: int) -> int:
|
|
||||||
if value < 0:
|
|
||||||
raise serializers.ValidationError("Max uses cannot be negative.")
|
|
||||||
return value
|
|
||||||
|
|
||||||
def create(self, validated_data: dict) -> GroupInvite:
|
|
||||||
group = self.context["group"]
|
|
||||||
user = self.context["request"].user
|
|
||||||
return GroupInvite.objects.create(
|
|
||||||
group=group,
|
|
||||||
created_by=user,
|
|
||||||
**validated_data,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class JoinRequestSerializer(serializers.ModelSerializer):
|
|
||||||
user_id = serializers.IntegerField(source="user.id", read_only=True)
|
|
||||||
user_email = serializers.CharField(source="user.email", read_only=True)
|
|
||||||
user_username = serializers.CharField(source="user.username", read_only=True)
|
|
||||||
group_name = serializers.CharField(source="group.name", read_only=True)
|
|
||||||
invite_code = serializers.UUIDField(source="invite.code", read_only=True, default=None)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = JoinRequest
|
|
||||||
fields = [
|
|
||||||
"id", "group", "group_name", "user", "user_id", "user_email",
|
|
||||||
"user_username", "invite", "invite_code", "status", "created_at",
|
|
||||||
]
|
|
||||||
read_only_fields = ["id", "group", "user", "invite", "created_at"]
|
|
||||||
|
|
||||||
|
|
||||||
class JoinViaInviteSerializer(serializers.Serializer):
|
|
||||||
"""Validates and processes joining a group via an invite code."""
|
|
||||||
|
|
||||||
code = serializers.UUIDField()
|
|
||||||
|
|
||||||
def validate_code(self, value: str) -> str:
|
|
||||||
try:
|
|
||||||
invite = GroupInvite.objects.select_related("group").get(code=value)
|
|
||||||
except GroupInvite.DoesNotExist:
|
|
||||||
raise serializers.ValidationError("Invalid invite code.")
|
|
||||||
|
|
||||||
if not invite.is_active:
|
|
||||||
raise serializers.ValidationError("This invite is no longer active.")
|
|
||||||
|
|
||||||
if invite.max_uses > 0 and invite.use_count >= invite.max_uses:
|
|
||||||
raise serializers.ValidationError("This invite has reached its maximum uses.")
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class RoleUpdateSerializer(serializers.Serializer):
|
|
||||||
role = serializers.ChoiceField(choices=GroupRole.choices)
|
|
||||||
|
|
||||||
def validate_role(self, value: str) -> str:
|
|
||||||
if value == GroupRole.MEMBER:
|
|
||||||
group = self.context["group"]
|
|
||||||
admin_count = group.memberships.filter(role=GroupRole.ADMIN).count()
|
|
||||||
if admin_count <= 1:
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
"Cannot remove the last admin. Transfer admin role first or dissolve the group."
|
|
||||||
)
|
|
||||||
return value
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
from django.urls import include, path
|
|
||||||
from rest_framework.routers import DefaultRouter
|
|
||||||
|
|
||||||
from apps.groups.views import GroupViewSet, JoinGroupViewSet
|
|
||||||
|
|
||||||
router = DefaultRouter()
|
|
||||||
router.register(r"groups", GroupViewSet, basename="group")
|
|
||||||
router.register(r"join", JoinGroupViewSet, basename="join-group")
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path("", include(router.urls)),
|
|
||||||
]
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.db.models import Count, Prefetch, QuerySet
|
|
||||||
from rest_framework import status, viewsets
|
|
||||||
from rest_framework.decorators import action
|
|
||||||
from rest_framework.permissions import IsAuthenticated
|
|
||||||
from rest_framework.request import Request
|
|
||||||
from rest_framework.response import Response
|
|
||||||
|
|
||||||
from apps.groups.models import Group, GroupInvite, GroupMember, GroupRole, JoinRequest, JoinRequestStatus
|
|
||||||
from apps.groups.permissions import IsGroupAdmin, IsGroupMember
|
|
||||||
from apps.groups.serializers import (
|
|
||||||
GroupCreateSerializer,
|
|
||||||
GroupDetailSerializer,
|
|
||||||
GroupInviteCreateSerializer,
|
|
||||||
GroupInviteSerializer,
|
|
||||||
GroupListSerializer,
|
|
||||||
GroupMemberSerializer,
|
|
||||||
GroupUpdateSerializer,
|
|
||||||
JoinRequestSerializer,
|
|
||||||
JoinViaInviteSerializer,
|
|
||||||
RoleUpdateSerializer,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GroupViewSet(viewsets.ModelViewSet):
|
|
||||||
permission_classes = [IsAuthenticated]
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
|
||||||
if self.action == "create":
|
|
||||||
return GroupCreateSerializer
|
|
||||||
if self.action in ("update", "partial_update"):
|
|
||||||
return GroupUpdateSerializer
|
|
||||||
if self.action == "retrieve":
|
|
||||||
return GroupDetailSerializer
|
|
||||||
return GroupListSerializer
|
|
||||||
|
|
||||||
def get_queryset(self) -> QuerySet[Group]:
|
|
||||||
user = self.request.user
|
|
||||||
qs = Group.objects.filter(memberships__user=user).distinct()
|
|
||||||
qs = qs.annotate(_member_count=Count("memberships"))
|
|
||||||
if self.action in ("list", "retrieve"):
|
|
||||||
qs = qs.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"memberships",
|
|
||||||
queryset=GroupMember.objects.select_related("user").order_by("joined_at"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return qs
|
|
||||||
|
|
||||||
def get_object(self) -> Group:
|
|
||||||
obj = super().get_object()
|
|
||||||
# Cache the requesting user's membership for serializers
|
|
||||||
try:
|
|
||||||
obj._user_membership = obj.memberships.get(user=self.request.user)
|
|
||||||
except GroupMember.DoesNotExist:
|
|
||||||
obj._user_membership = None
|
|
||||||
return obj
|
|
||||||
|
|
||||||
def perform_create(self, serializer: GroupCreateSerializer) -> Group:
|
|
||||||
return serializer.save()
|
|
||||||
|
|
||||||
def perform_destroy(self, instance: Group) -> None:
|
|
||||||
# Only admin can delete/dissolve the group
|
|
||||||
if not GroupMember.objects.filter(
|
|
||||||
group=instance, user=self.request.user, role=GroupRole.ADMIN
|
|
||||||
).exists():
|
|
||||||
from rest_framework.exceptions import PermissionDenied
|
|
||||||
|
|
||||||
raise PermissionDenied("Only group admins can delete the group.")
|
|
||||||
instance.delete()
|
|
||||||
|
|
||||||
# ---- Members ----
|
|
||||||
|
|
||||||
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated, IsGroupMember])
|
|
||||||
def members(self, request: Request, pk: str | None = None) -> Response:
|
|
||||||
"""List all members of the group."""
|
|
||||||
group = self.get_object()
|
|
||||||
memberships = group.memberships.select_related("user").order_by("joined_at")
|
|
||||||
serializer = GroupMemberSerializer(memberships, many=True)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@action(
|
|
||||||
detail=True,
|
|
||||||
methods=["delete"],
|
|
||||||
url_path="members/(?P<user_id>[^/.]+)",
|
|
||||||
permission_classes=[IsAuthenticated, IsGroupAdmin],
|
|
||||||
)
|
|
||||||
def remove_member(self, request: Request, pk: str | None = None, user_id: str | None = None) -> Response:
|
|
||||||
"""Admin removes a member from the group."""
|
|
||||||
group = self.get_object()
|
|
||||||
try:
|
|
||||||
membership = GroupMember.objects.get(group=group, user_id=user_id)
|
|
||||||
except GroupMember.DoesNotExist:
|
|
||||||
return Response({"error": "Member not found."}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
if membership.user == request.user:
|
|
||||||
return Response(
|
|
||||||
{"error": "Admins cannot remove themselves. Use leave instead, or transfer admin first."},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
membership.delete()
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
@action(
|
|
||||||
detail=True,
|
|
||||||
methods=["patch"],
|
|
||||||
url_path="members/(?P<user_id>[^/.]+)/role",
|
|
||||||
permission_classes=[IsAuthenticated, IsGroupAdmin],
|
|
||||||
)
|
|
||||||
def update_member_role(self, request: Request, pk: str | None = None, user_id: str | None = None) -> Response:
|
|
||||||
"""Admin transfers admin role or changes member role."""
|
|
||||||
group = self.get_object()
|
|
||||||
serializer = RoleUpdateSerializer(data=request.data, context={"group": group})
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
membership = GroupMember.objects.get(group=group, user_id=user_id)
|
|
||||||
except GroupMember.DoesNotExist:
|
|
||||||
return Response({"error": "Member not found."}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
membership.role = serializer.validated_data["role"]
|
|
||||||
membership.save(update_fields=["role"])
|
|
||||||
|
|
||||||
if serializer.validated_data["role"] == GroupRole.ADMIN and membership.user != request.user:
|
|
||||||
# Downgrade the current admin to member
|
|
||||||
GroupMember.objects.filter(group=group, user=request.user).update(role=GroupRole.MEMBER)
|
|
||||||
|
|
||||||
return Response(GroupMemberSerializer(membership).data)
|
|
||||||
|
|
||||||
@action(detail=True, methods=["post"], permission_classes=[IsAuthenticated, IsGroupMember])
|
|
||||||
def leave(self, request: Request, pk: str | None = None) -> Response:
|
|
||||||
"""Member leaves the group. If admin is last admin, dissolve the group."""
|
|
||||||
group = self.get_object()
|
|
||||||
membership = GroupMember.objects.filter(group=group, user=request.user).first()
|
|
||||||
|
|
||||||
if not membership:
|
|
||||||
return Response({"error": "You are not a member of this group."}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
if membership.role == GroupRole.ADMIN:
|
|
||||||
admin_count = GroupMember.objects.filter(group=group, role=GroupRole.ADMIN).count()
|
|
||||||
if admin_count <= 1:
|
|
||||||
# Last admin leaving — dissolve the group
|
|
||||||
group.delete()
|
|
||||||
return Response({"detail": "You were the last admin. The group has been dissolved."})
|
|
||||||
|
|
||||||
membership.delete()
|
|
||||||
return Response({"detail": "You have left the group."})
|
|
||||||
|
|
||||||
# ---- Invites ----
|
|
||||||
|
|
||||||
@action(detail=True, methods=["get", "post"], permission_classes=[IsAuthenticated, IsGroupAdmin])
|
|
||||||
def invites(self, request: Request, pk: str | None = None) -> Response:
|
|
||||||
"""List or create invites for the group."""
|
|
||||||
group = self.get_object()
|
|
||||||
|
|
||||||
if request.method == "GET":
|
|
||||||
invites_qs = group.invites.select_related("created_by").order_by("-created_at")
|
|
||||||
serializer = GroupInviteSerializer(invites_qs, many=True, context={"request": request})
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
serializer = GroupInviteCreateSerializer(
|
|
||||||
data=request.data,
|
|
||||||
context={"group": group, "request": request},
|
|
||||||
)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
invite = serializer.save()
|
|
||||||
return Response(
|
|
||||||
GroupInviteSerializer(invite, context={"request": request}).data,
|
|
||||||
status=status.HTTP_201_CREATED,
|
|
||||||
)
|
|
||||||
|
|
||||||
@action(
|
|
||||||
detail=True,
|
|
||||||
methods=["delete"],
|
|
||||||
url_path="invites/(?P<invite_id>[^/.]+)",
|
|
||||||
permission_classes=[IsAuthenticated, IsGroupAdmin],
|
|
||||||
)
|
|
||||||
def revoke_invite(self, request: Request, pk: str | None = None, invite_id: str | None = None) -> Response:
|
|
||||||
"""Revoke an invite by deactivating it."""
|
|
||||||
group = self.get_object()
|
|
||||||
try:
|
|
||||||
invite = GroupInvite.objects.get(id=invite_id, group=group)
|
|
||||||
except GroupInvite.DoesNotExist:
|
|
||||||
return Response({"error": "Invite not found."}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
invite.is_active = False
|
|
||||||
invite.save(update_fields=["is_active"])
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
# ---- Join Requests ----
|
|
||||||
|
|
||||||
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated, IsGroupAdmin])
|
|
||||||
def requests(self, request: Request, pk: str | None = None) -> Response:
|
|
||||||
"""List pending join requests for the group (admin only)."""
|
|
||||||
group = self.get_object()
|
|
||||||
join_requests = group.join_requests.select_related("user", "invite").order_by("-created_at")
|
|
||||||
serializer = JoinRequestSerializer(join_requests, many=True)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@action(
|
|
||||||
detail=True,
|
|
||||||
methods=["post"],
|
|
||||||
url_path="requests/(?P<request_id>[^/.]+)/approve",
|
|
||||||
permission_classes=[IsAuthenticated, IsGroupAdmin],
|
|
||||||
)
|
|
||||||
def approve_request(self, request: Request, pk: str | None = None, request_id: str | None = None) -> Response:
|
|
||||||
"""Approve a pending join request."""
|
|
||||||
group = self.get_object()
|
|
||||||
try:
|
|
||||||
join_request = JoinRequest.objects.get(id=request_id, group=group, status=JoinRequestStatus.PENDING)
|
|
||||||
except JoinRequest.DoesNotExist:
|
|
||||||
return Response({"error": "Pending join request not found."}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
join_request.status = JoinRequestStatus.APPROVED
|
|
||||||
join_request.save(update_fields=["status"])
|
|
||||||
|
|
||||||
GroupMember.objects.get_or_create(
|
|
||||||
group=group,
|
|
||||||
user=join_request.user,
|
|
||||||
defaults={"role": GroupRole.MEMBER},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Increment invite use count
|
|
||||||
if join_request.invite:
|
|
||||||
join_request.invite.use_count += 1
|
|
||||||
join_request.invite.save(update_fields=["use_count"])
|
|
||||||
|
|
||||||
return Response(JoinRequestSerializer(join_request).data)
|
|
||||||
|
|
||||||
@action(
|
|
||||||
detail=True,
|
|
||||||
methods=["post"],
|
|
||||||
url_path="requests/(?P<request_id>[^/.]+)/reject",
|
|
||||||
permission_classes=[IsAuthenticated, IsGroupAdmin],
|
|
||||||
)
|
|
||||||
def reject_request(self, request: Request, pk: str | None = None, request_id: str | None = None) -> Response:
|
|
||||||
"""Reject a pending join request."""
|
|
||||||
group = self.get_object()
|
|
||||||
try:
|
|
||||||
join_request = JoinRequest.objects.get(id=request_id, group=group, status=JoinRequestStatus.PENDING)
|
|
||||||
except JoinRequest.DoesNotExist:
|
|
||||||
return Response({"error": "Pending join request not found."}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
join_request.status = JoinRequestStatus.REJECTED
|
|
||||||
join_request.save(update_fields=["status"])
|
|
||||||
return Response(JoinRequestSerializer(join_request).data)
|
|
||||||
|
|
||||||
|
|
||||||
class JoinGroupViewSet(viewsets.GenericViewSet):
|
|
||||||
"""Public(ish) endpoint for joining a group via an invite code."""
|
|
||||||
|
|
||||||
permission_classes = [IsAuthenticated]
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"], url_path="(?P<code>[^/.]+)")
|
|
||||||
def validate_invite(self, request: Request, code: str | None = None) -> Response:
|
|
||||||
"""Check if an invite code is valid and show group info."""
|
|
||||||
try:
|
|
||||||
invite = GroupInvite.objects.select_related("group", "group__created_by").get(code=code)
|
|
||||||
except GroupInvite.DoesNotExist:
|
|
||||||
return Response({"error": "Invalid invite code."}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
if not invite.is_active:
|
|
||||||
return Response({"error": "This invite is no longer active."}, status=status.HTTP_410_GONE)
|
|
||||||
|
|
||||||
if invite.max_uses > 0 and invite.use_count >= invite.max_uses:
|
|
||||||
return Response({"error": "This invite has reached its maximum uses."}, status=status.HTTP_410_GONE)
|
|
||||||
|
|
||||||
return Response({
|
|
||||||
"group": {
|
|
||||||
"id": invite.group.id,
|
|
||||||
"name": invite.group.name,
|
|
||||||
"description": invite.group.description,
|
|
||||||
"created_by_email": invite.group.created_by.email,
|
|
||||||
"member_count": invite.group.memberships.count(),
|
|
||||||
},
|
|
||||||
"invite": {
|
|
||||||
"code": str(invite.code),
|
|
||||||
"created_by_email": invite.created_by.email,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
@action(detail=False, methods=["post"], url_path="(?P<code>[^/.]+)")
|
|
||||||
def join(self, request: Request, code: str | None = None) -> Response:
|
|
||||||
"""Join a group via invite code."""
|
|
||||||
try:
|
|
||||||
invite = GroupInvite.objects.select_related("group").get(code=code)
|
|
||||||
except GroupInvite.DoesNotExist:
|
|
||||||
return Response({"error": "Invalid invite code."}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
if not invite.is_active:
|
|
||||||
return Response({"error": "This invite is no longer active."}, status=status.HTTP_410_GONE)
|
|
||||||
|
|
||||||
if invite.max_uses > 0 and invite.use_count >= invite.max_uses:
|
|
||||||
return Response({"error": "This invite has reached its maximum uses."}, status=status.HTTP_410_GONE)
|
|
||||||
|
|
||||||
group = invite.group
|
|
||||||
|
|
||||||
# Check if already a member
|
|
||||||
if GroupMember.objects.filter(group=group, user=request.user).exists():
|
|
||||||
return Response(
|
|
||||||
{"detail": "You are already a member of this group.", "group_id": group.id},
|
|
||||||
status=status.HTTP_200_OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for existing pending request
|
|
||||||
existing_request = JoinRequest.objects.filter(
|
|
||||||
group=group, user=request.user, status=JoinRequestStatus.PENDING
|
|
||||||
).first()
|
|
||||||
if existing_request:
|
|
||||||
return Response(
|
|
||||||
JoinRequestSerializer(existing_request).data,
|
|
||||||
status=status.HTTP_200_OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create join request or add directly (direct join for now — simple invite flow)
|
|
||||||
member = GroupMember.objects.create(group=group, user=request.user, role=GroupRole.MEMBER)
|
|
||||||
invite.use_count += 1
|
|
||||||
invite.save(update_fields=["use_count"])
|
|
||||||
|
|
||||||
# Also create a join request record for tracking
|
|
||||||
JoinRequest.objects.create(
|
|
||||||
group=group,
|
|
||||||
user=request.user,
|
|
||||||
invite=invite,
|
|
||||||
status=JoinRequestStatus.APPROVED,
|
|
||||||
)
|
|
||||||
|
|
||||||
serializer = GroupDetailSerializer(group, context={"request": request})
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class ReaderConfig(AppConfig):
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
|
||||||
name = "apps.reader"
|
|
||||||
verbose_name = "Reader Settings"
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# 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',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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)"
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from django.urls import path
|
|
||||||
|
|
||||||
from apps.reader.views import reading_settings_view
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path("settings/", reading_settings_view, name="reading-settings"),
|
|
||||||
]
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# 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()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
from django.urls import path
|
from django.urls import include, path
|
||||||
from rest_framework_simplejwt.views import TokenRefreshView
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||||
from apps.users.views import EmailTokenObtainPairView, RegisterView
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("register/", RegisterView.as_view(), name="register"),
|
path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
|
||||||
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"),
|
||||||
]
|
]
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -40,8 +40,6 @@ INSTALLED_APPS = [
|
|||||||
"apps.users",
|
"apps.users",
|
||||||
"apps.books",
|
"apps.books",
|
||||||
"apps.annotations",
|
"apps.annotations",
|
||||||
"apps.reader",
|
|
||||||
"apps.groups",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@@ -146,7 +144,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
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
@@ -26,14 +29,6 @@ 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 (
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -7,11 +6,4 @@ 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")),
|
|
||||||
path("api/", include("apps.groups.urls")),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG:
|
|
||||||
from django.conf.urls.static import static
|
|
||||||
|
|
||||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
[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",
|
|
||||||
]
|
|
||||||
Generated
-673
@@ -1,673 +0,0 @@
|
|||||||
version = 1
|
|
||||||
revision = 3
|
|
||||||
requires-python = ">=3.12"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "annotated-types"
|
|
||||||
version = "0.7.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "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
-12
@@ -5,9 +5,9 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-cloud_reader}
|
POSTGRES_DB: cloud_reader
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
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: ${DJANGO_SECRET_KEY}
|
DJANGO_SECRET_KEY: "dev-secret-key-change-in-production"
|
||||||
DJANGO_DEBUG: ${DJANGO_DEBUG:-True}
|
DJANGO_DEBUG: "True"
|
||||||
DB_NAME: ${POSTGRES_DB:-cloud_reader}
|
DB_NAME: cloud_reader
|
||||||
DB_USER: ${POSTGRES_USER:-postgres}
|
DB_USER: postgres
|
||||||
DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
DB_PASSWORD: postgres
|
||||||
DB_HOST: ${DB_HOST:-db}
|
DB_HOST: db
|
||||||
DB_PORT: ${DB_PORT:-5432}
|
DB_PORT: "5432"
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- ./backend/media:/app/media
|
- book_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: ${VITE_API_URL:-http://localhost:8000}
|
VITE_API_URL: "http://localhost:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
@@ -55,3 +55,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
book_media:
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
# Book Search & Discovery — Spec
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Enable users to search books within the library and discover new books via filters and a dedicated detail view.
|
|
||||||
|
|
||||||
## Backend API Contracts
|
|
||||||
|
|
||||||
### Book List & Search
|
|
||||||
`GET /api/books/`
|
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
| Param | Type | Description |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| `q` | string | Full-text search across title, author, genre |
|
|
||||||
| `genre` | string | Exact filter by genre |
|
|
||||||
| `author` | string | Exact filter by author |
|
|
||||||
| `reading_status` | string | Filter: `want_to_read`, `reading`, `finished`, `dnf` |
|
|
||||||
| `ordering` | string | `title`, `author`, `genre`, `created_at` (prefix `-` for desc) |
|
|
||||||
| `page` | int | Page number (default: 1) |
|
|
||||||
|
|
||||||
**Response (paginated):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"count": 42,
|
|
||||||
"next": "http://.../?page=2",
|
|
||||||
"previous": null,
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"title": "Dune",
|
|
||||||
"author": "Frank Herbert",
|
|
||||||
"genre": "Science Fiction",
|
|
||||||
"reading_status": "finished",
|
|
||||||
"reading_status_display": "Finished",
|
|
||||||
"cover_image": "https://..."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Book Detail
|
|
||||||
`GET /api/books/{id}/`
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"title": "Dune",
|
|
||||||
"author": "Frank Herbert",
|
|
||||||
"genre": "Science Fiction",
|
|
||||||
"description": "...",
|
|
||||||
"reading_status": "finished",
|
|
||||||
"reading_status_display": "Finished",
|
|
||||||
"cover_image": "https://...",
|
|
||||||
"total_pages": 688,
|
|
||||||
"created_at": "2025-01-01T00:00:00Z",
|
|
||||||
"updated_at": "2025-01-15T00:00:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Genre / Author Discovery
|
|
||||||
`GET /api/books/genres/` → `["Fiction", "Science Fiction", ...]`
|
|
||||||
|
|
||||||
`GET /api/books/authors/` → `["Frank Herbert", "Ursula K. Le Guin", ...]`
|
|
||||||
|
|
||||||
## Frontend Components
|
|
||||||
|
|
||||||
### LibraryPage (enhanced)
|
|
||||||
- **Search bar** at top: text input with debounced `onChange` → calls API with `q` param
|
|
||||||
- **Filter row**: genre dropdown, author dropdown, reading status dropdown
|
|
||||||
- Genre/Author dropdowns populated from `/api/books/genres/` and `/api/books/authors/`
|
|
||||||
- Reading status uses static enum values (`READING_STATUS_OPTIONS`)
|
|
||||||
- **Results grid**: card layout showing cover, title, author, reading status badge
|
|
||||||
- **Empty state**: "No books found" with clear message when results are empty
|
|
||||||
- **Loading state**: spinner/skeleton while fetching
|
|
||||||
- **Click card → navigate to** `/books/{id}`
|
|
||||||
|
|
||||||
### BookDetailPage (new)
|
|
||||||
- Shows full book info: cover, title, author, genre, description, reading status, total pages
|
|
||||||
- Back button to return to library
|
|
||||||
- Clean, mobile-responsive layout
|
|
||||||
|
|
||||||
### API Client — `frontend/src/api/books.ts`
|
|
||||||
|
|
||||||
| Method | Endpoint | Returns |
|
|
||||||
|--------|----------|---------|
|
|
||||||
| `searchBooks(params)` | `GET /api/books/` | `{count, results: BookListItem[]}` |
|
|
||||||
| `getBook(id)` | `GET /api/books/{id}/` | `BookDetail` |
|
|
||||||
| `getGenres()` | `GET /api/books/genres/` | `string[]` |
|
|
||||||
| `getAuthors()` | `GET /api/books/authors/` | `string[]` |
|
|
||||||
|
|
||||||
## Routes (Frontend)
|
|
||||||
| Path | Component | Auth |
|
|
||||||
|------|-----------|------|
|
|
||||||
| `/` | LibraryPage | Protected |
|
|
||||||
| `/books/:id` | BookDetailPage | Protected |
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
1. User types in search bar → 300ms debounce → `GET /api/books/?q=...`
|
|
||||||
2. User selects filter → `GET /api/books/?genre=...&author=...&reading_status=...`
|
|
||||||
3. User clicks result → navigate to `/books/:id`
|
|
||||||
4. BookDetailPage → `GET /api/books/{id}/`
|
|
||||||
|
|
||||||
## Mobile Optimizations
|
|
||||||
- Filters collapse into a toggleable panel on small screens
|
|
||||||
- Cards stack in single column on mobile
|
|
||||||
- Touch-friendly tap targets (min 44px)
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# 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 (0–100) 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
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# 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` (0–100) 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 | — |
|
|
||||||
| `1–98` | — | 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
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
# 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`
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
# 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)
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
# 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
@@ -1 +0,0 @@
|
|||||||
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
@@ -1 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
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
@@ -1 +0,0 @@
|
|||||||
._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}
|
|
||||||
-1
File diff suppressed because one or more lines are too long
-22
File diff suppressed because one or more lines are too long
-1
@@ -1 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
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
@@ -1 +0,0 @@
|
|||||||
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
@@ -1 +0,0 @@
|
|||||||
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};
|
|
||||||
-70
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
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};
|
|
||||||
Vendored
-13
@@ -1,13 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -11,23 +11,20 @@
|
|||||||
"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-i18n-lite": "^1.0.10",
|
"react-router-dom": "^7.1.0",
|
||||||
"react-reader": "^2.0.15"
|
"axios": "^1.7.9"
|
||||||
},
|
},
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+16
-36
@@ -1,27 +1,24 @@
|
|||||||
import React, { lazy, Suspense } from "react";
|
import React, { lazy, Suspense, useState } from "react";
|
||||||
import { BrowserRouter, Navigate, Route, Routes, useParams } from "react-router-dom";
|
import { BrowserRouter, Navigate, Route, Routes } 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 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 GroupsListPage = lazy(() => import("./pages/GroupsListPage").then((m) => ({ default: m.GroupsListPage })));
|
|
||||||
const GroupDetailPage = lazy(() => import("./pages/GroupDetailPage").then((m) => ({ default: m.GroupDetailPage })));
|
|
||||||
const CreateGroupPage = lazy(() => import("./pages/CreateGroupPage").then((m) => ({ default: m.CreateGroupPage })));
|
|
||||||
const JoinGroupPage = lazy(() => import("./pages/JoinGroupPage").then((m) => ({ default: m.JoinGroupPage })));
|
|
||||||
|
|
||||||
const AuthPage = lazy(() => import("./pages/AuthPage"));
|
const AuthPage = lazy(() =>
|
||||||
|
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() {
|
||||||
const { t } = useTranslation();
|
return <div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", color: "#888", fontSize: 16 }}><p>Loading...</p></div>;
|
||||||
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 }) {
|
||||||
@@ -31,11 +28,6 @@ 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 (
|
||||||
@@ -43,16 +35,10 @@ function AppRoutes() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<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="/reader/:id" element={<ProtectedRoute><ReaderPage /></ProtectedRoute>} />
|
||||||
<Route path="/read/:id" element={<ProtectedRoute><ReadingPage /></ProtectedRoute>} />
|
|
||||||
<Route path="/reader/:id" element={<ProtectedRoute><ReaderRedirect /></ProtectedRoute>} />
|
|
||||||
<Route path="/add" element={<ProtectedRoute><AddBookPage /></ProtectedRoute>} />
|
<Route path="/add" element={<ProtectedRoute><AddBookPage /></ProtectedRoute>} />
|
||||||
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
|
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
|
||||||
<Route path="/bookmarks-notes/:bookId?" element={<ProtectedRoute><BookmarksNotesPage /></ProtectedRoute>} />
|
<Route path="/bookmarks-notes/:bookId?" element={<ProtectedRoute><BookmarksNotesPage /></ProtectedRoute>} />
|
||||||
<Route path="/groups" element={<ProtectedRoute><GroupsListPage /></ProtectedRoute>} />
|
|
||||||
<Route path="/groups/create" element={<ProtectedRoute><CreateGroupPage /></ProtectedRoute>} />
|
|
||||||
<Route path="/groups/join/:code" element={<ProtectedRoute><JoinGroupPage /></ProtectedRoute>} />
|
|
||||||
<Route path="/groups/:id" element={<ProtectedRoute><GroupDetailPage /></ProtectedRoute>} />
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
@@ -62,15 +48,9 @@ function AppRoutes() {
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<I18nProvider>
|
<AuthProvider>
|
||||||
<AuthProvider>
|
<AppRoutes />
|
||||||
<ToastProvider>
|
</AuthProvider>
|
||||||
<AnnotationsProvider>
|
|
||||||
<AppRoutes />
|
|
||||||
</AnnotationsProvider>
|
|
||||||
</ToastProvider>
|
|
||||||
</AuthProvider>
|
|
||||||
</I18nProvider>
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,29 +1,96 @@
|
|||||||
import api from "@/api/client";
|
import api from "@/api/client";
|
||||||
import type {
|
import type {
|
||||||
Bookmark,
|
Bookmark,
|
||||||
CreateMarkerPayload,
|
CreateBookmarkPayload,
|
||||||
|
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(
|
||||||
ebookId?: string | number,
|
bookId?: string
|
||||||
): Promise<PaginatedResponse<Bookmark>> {
|
): Promise<PaginatedResponse<Bookmark>> {
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
if (ebookId != null && ebookId !== "") params.ebook = String(ebookId);
|
if (bookId) params.book = bookId;
|
||||||
const { data } = await api.get<PaginatedResponse<Bookmark>>(
|
const { data } = await api.get<PaginatedResponse<Bookmark>>(
|
||||||
"/annotations/bookmarks/",
|
"/annotations/bookmarks/",
|
||||||
{ params },
|
{ params }
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createMarker(
|
/** Create a new bookmark */
|
||||||
payload: CreateMarkerPayload,
|
export async function createBookmark(
|
||||||
|
payload: CreateBookmarkPayload
|
||||||
): Promise<Bookmark> {
|
): Promise<Bookmark> {
|
||||||
const { data } = await api.post<Bookmark>("/annotations/bookmarks/", payload);
|
const { data } = await api.post<Bookmark>(
|
||||||
|
"/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;
|
||||||
|
}
|
||||||
@@ -1,21 +1,10 @@
|
|||||||
import api from "./client";
|
import api from "./client";
|
||||||
import type {
|
import type { EBookDetail, EBookListItem, ReadingProgress, ReadingSettings } from "../types/book";
|
||||||
BookDetail,
|
|
||||||
BookListItem,
|
|
||||||
BookSearchParams,
|
|
||||||
ContentResponse,
|
|
||||||
EBookDetail,
|
|
||||||
EBookListItem,
|
|
||||||
ReadingProgress,
|
|
||||||
ReadingSettings,
|
|
||||||
TocResponse,
|
|
||||||
} from "../types/book";
|
|
||||||
|
|
||||||
export const booksApi = {
|
export const booksApi = {
|
||||||
async getEBooks(): Promise<EBookListItem[]> {
|
async getEBooks(): Promise<EBookListItem[]> {
|
||||||
const { data } = await api.get<{ count: number; results: EBookListItem[] } | EBookListItem[]>("/books/ebooks/");
|
const { data } = await api.get<EBookListItem[]>("/books/ebooks/");
|
||||||
if (Array.isArray(data)) return data;
|
return data;
|
||||||
return data.results ?? [];
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async getEBook(id: number): Promise<EBookDetail> {
|
async getEBook(id: number): Promise<EBookDetail> {
|
||||||
@@ -23,53 +12,6 @@ export const booksApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getEbookFile(id: number): Promise<Blob> {
|
|
||||||
const { data } = await api.get<Blob>(`/books/ebooks/${id}/file/`, {
|
|
||||||
responseType: "blob",
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getEpubFile(id: number): Promise<Blob> {
|
|
||||||
const { data } = await api.get<Blob>(`/books/ebooks/${id}/file/`, {
|
|
||||||
responseType: "blob",
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async searchBooks(params: BookSearchParams = {}): Promise<{ count: number; results: BookListItem[] }> {
|
|
||||||
const { data } = await api.get<{ count: number; results: BookListItem[] } | BookListItem[]>("/books/", { params });
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
return { count: data.length, results: data };
|
|
||||||
}
|
|
||||||
return { count: data.count ?? 0, results: data.results ?? [] };
|
|
||||||
},
|
|
||||||
|
|
||||||
async getBook(id: number): Promise<BookDetail> {
|
|
||||||
const { data } = await api.get<BookDetail>(`/books/${id}/`);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getGenres(): Promise<string[]> {
|
|
||||||
const { data } = await api.get<string[]>("/books/ebooks/genres/");
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getAuthors(): Promise<string[]> {
|
|
||||||
const { data } = await api.get<string[]>("/books/ebooks/authors/");
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async searchEBooks(params: { q: string; page_size?: number }): Promise<EBookListItem[]> {
|
|
||||||
const { data } = await api.get<{ count: number; results: EBookListItem[] } | EBookListItem[]>(
|
|
||||||
"/books/ebooks/",
|
|
||||||
{ params: { q: params.q.trim() } },
|
|
||||||
);
|
|
||||||
const items = Array.isArray(data) ? data : data.results ?? [];
|
|
||||||
const limit = params.page_size ?? items.length;
|
|
||||||
return items.slice(0, limit);
|
|
||||||
},
|
|
||||||
|
|
||||||
async uploadEBook(
|
async uploadEBook(
|
||||||
file: File,
|
file: File,
|
||||||
title: string,
|
title: string,
|
||||||
@@ -92,26 +34,6 @@ 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 }> {
|
|
||||||
const { data } = await api.post<{ status: string }>(`/books/ebooks/${id}/process/`);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getToc(id: number): Promise<TocResponse> {
|
|
||||||
const { data } = await api.get<TocResponse>(`/books/ebooks/${id}/toc/`);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getContent(id: number, page: number): Promise<ContentResponse> {
|
|
||||||
const { data } = await api.get<ContentResponse>(`/books/ebooks/${id}/content/?page=${page}`);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getProgress(ebookId: number): Promise<ReadingProgress> {
|
async getProgress(ebookId: number): Promise<ReadingProgress> {
|
||||||
const { data } = await api.get<ReadingProgress>(`/books/ebooks/${ebookId}/progress/`);
|
const { data } = await api.get<ReadingProgress>(`/books/ebooks/${ebookId}/progress/`);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -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 = "/auth";
|
window.location.href = "/login";
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
} finally {
|
} finally {
|
||||||
isRefreshing = false;
|
isRefreshing = false;
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import api from "./client";
|
|
||||||
import type {
|
|
||||||
CreateGroupPayload,
|
|
||||||
CreateInvitePayload,
|
|
||||||
GroupDetail,
|
|
||||||
GroupInvite,
|
|
||||||
GroupListItem,
|
|
||||||
InviteValidation,
|
|
||||||
JoinRequest,
|
|
||||||
UpdateGroupPayload,
|
|
||||||
} from "../../packages/shared/src/types";
|
|
||||||
|
|
||||||
export const groupsApi = {
|
|
||||||
// ---- Group CRUD ----
|
|
||||||
|
|
||||||
async listGroups(): Promise<GroupListItem[]> {
|
|
||||||
const { data } = await api.get<{ count: number; results: GroupListItem[] } | GroupListItem[]>("/groups/");
|
|
||||||
if (Array.isArray(data)) return data;
|
|
||||||
return data.results ?? [];
|
|
||||||
},
|
|
||||||
|
|
||||||
async getGroup(id: number): Promise<GroupDetail> {
|
|
||||||
const { data } = await api.get<GroupDetail>(`/groups/${id}/`);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async createGroup(payload: CreateGroupPayload): Promise<GroupDetail> {
|
|
||||||
const { data } = await api.post<GroupDetail>("/groups/", payload);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateGroup(id: number, payload: UpdateGroupPayload): Promise<GroupDetail> {
|
|
||||||
const { data } = await api.patch<GroupDetail>(`/groups/${id}/`, payload);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteGroup(id: number): Promise<void> {
|
|
||||||
await api.delete(`/groups/${id}/`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// ---- Members ----
|
|
||||||
|
|
||||||
async removeMember(groupId: number, userId: number): Promise<void> {
|
|
||||||
await api.delete(`/groups/${groupId}/members/${userId}/`);
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateMemberRole(groupId: number, userId: number, role: "admin" | "member"): Promise<void> {
|
|
||||||
await api.patch(`/groups/${groupId}/members/${userId}/role/`, { role });
|
|
||||||
},
|
|
||||||
|
|
||||||
async leaveGroup(groupId: number): Promise<{ detail: string }> {
|
|
||||||
const { data } = await api.post<{ detail: string }>(`/groups/${groupId}/leave/`);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// ---- Invites ----
|
|
||||||
|
|
||||||
async listInvites(groupId: number): Promise<GroupInvite[]> {
|
|
||||||
const { data } = await api.get<GroupInvite[]>(`/groups/${groupId}/invites/`);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async createInvite(groupId: number, payload: CreateInvitePayload = {}): Promise<GroupInvite> {
|
|
||||||
const { data } = await api.post<GroupInvite>(`/groups/${groupId}/invites/`, payload);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async revokeInvite(groupId: number, inviteId: number): Promise<void> {
|
|
||||||
await api.delete(`/groups/${groupId}/invites/${inviteId}/`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// ---- Join Requests ----
|
|
||||||
|
|
||||||
async listJoinRequests(groupId: number): Promise<JoinRequest[]> {
|
|
||||||
const { data } = await api.get<JoinRequest[]>(`/groups/${groupId}/requests/`);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async approveRequest(groupId: number, requestId: number): Promise<JoinRequest> {
|
|
||||||
const { data } = await api.post<JoinRequest>(`/groups/${groupId}/requests/${requestId}/approve/`);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async rejectRequest(groupId: number, requestId: number): Promise<JoinRequest> {
|
|
||||||
const { data } = await api.post<JoinRequest>(`/groups/${groupId}/requests/${requestId}/reject/`);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// ---- Join via Invite ----
|
|
||||||
|
|
||||||
async validateInvite(code: string): Promise<InviteValidation> {
|
|
||||||
const { data } = await api.get<InviteValidation>(`/join/${code}/`);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async joinViaInvite(code: string): Promise<GroupDetail> {
|
|
||||||
const { data } = await api.post<GroupDetail>(`/join/${code}/`);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 ?? "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18n-lite";
|
|
||||||
import { booksApi } from "../api/books";
|
|
||||||
import { getApiErrorMessage } from "../api/errors";
|
|
||||||
import { useToast } from "../hooks/useToast";
|
|
||||||
import type { BookListItem } from "../types/book";
|
|
||||||
import type { SupportedLanguage } from "../locales";
|
|
||||||
import { subjectsFromMetadata } from "../utils/ebookLibrary";
|
|
||||||
import styles from "./BookContextMenu.module.css";
|
|
||||||
|
|
||||||
type LibraryBook = BookListItem & {
|
|
||||||
format: string;
|
|
||||||
progressPercent: number | null;
|
|
||||||
subjects: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
interface BookContextMenuProps {
|
|
||||||
book: LibraryBook;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
onClose: () => void;
|
|
||||||
onBookUpdated: (book: LibraryBook) => void;
|
|
||||||
onBookRemoved: (id: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActionKind = "sync" | "remove" | null;
|
|
||||||
|
|
||||||
function clampPosition(x: number, y: number, width: number, height: number) {
|
|
||||||
const padding = 8;
|
|
||||||
const maxX = window.innerWidth - width - padding;
|
|
||||||
const maxY = window.innerHeight - height - padding;
|
|
||||||
return {
|
|
||||||
left: Math.max(padding, Math.min(x, maxX)),
|
|
||||||
top: Math.max(padding, Math.min(y, maxY)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BookContextMenu({
|
|
||||||
book,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
onClose,
|
|
||||||
onBookUpdated,
|
|
||||||
onBookRemoved,
|
|
||||||
}: BookContextMenuProps) {
|
|
||||||
const { t, language } = useTranslation();
|
|
||||||
const locale = language as SupportedLanguage;
|
|
||||||
const { showToast } = useToast();
|
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [position, setPosition] = useState({ left: x, top: y });
|
|
||||||
const [activeAction, setActiveAction] = useState<ActionKind>(null);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const el = menuRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
setPosition(clampPosition(x, y, el.offsetWidth, el.offsetHeight));
|
|
||||||
}, [x, y]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handlePointerDown = (e: MouseEvent) => {
|
|
||||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
const handleScroll = () => onClose();
|
|
||||||
|
|
||||||
document.addEventListener("mousedown", handlePointerDown);
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
window.addEventListener("scroll", handleScroll, true);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", handlePointerDown);
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
window.removeEventListener("scroll", handleScroll, true);
|
|
||||||
};
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const handleSyncMetadata = async () => {
|
|
||||||
setActiveAction("sync");
|
|
||||||
try {
|
|
||||||
const updated = await booksApi.enrichEBookMetadata(book.id);
|
|
||||||
const subjects = subjectsFromMetadata(updated.metadata, locale);
|
|
||||||
onBookUpdated({
|
|
||||||
...book,
|
|
||||||
id: updated.id,
|
|
||||||
title: updated.title,
|
|
||||||
author: updated.author,
|
|
||||||
subjects,
|
|
||||||
genre: subjects[0] ?? "",
|
|
||||||
format: updated.format,
|
|
||||||
cover_image: updated.cover_image,
|
|
||||||
progressPercent: book.progressPercent,
|
|
||||||
});
|
|
||||||
|
|
||||||
const status = updated.metadata?.match_status;
|
|
||||||
if (status === "matched") {
|
|
||||||
showToast({ message: t("toast.metadataSynced", { title: updated.title }), variant: "success" });
|
|
||||||
} else if (status === "not_found") {
|
|
||||||
showToast({ message: t("toast.noMatch"), variant: "warning" });
|
|
||||||
} else {
|
|
||||||
showToast({ message: t("toast.refreshDone"), variant: "success" });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
showToast({ message: getApiErrorMessage(err, t("common.unknownError")), variant: "error" });
|
|
||||||
} finally {
|
|
||||||
setActiveAction(null);
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemove = async () => {
|
|
||||||
const confirmed = window.confirm(t("contextMenu.confirmRemove", { title: book.title }));
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
setActiveAction("remove");
|
|
||||||
try {
|
|
||||||
await booksApi.deleteEBook(book.id);
|
|
||||||
onBookRemoved(book.id);
|
|
||||||
showToast({ message: t("toast.removed", { title: book.title }), variant: "success" });
|
|
||||||
} catch (err) {
|
|
||||||
showToast({ message: getApiErrorMessage(err, t("common.unknownError")), variant: "error" });
|
|
||||||
} finally {
|
|
||||||
setActiveAction(null);
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const busy = activeAction !== null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={menuRef}
|
|
||||||
className={styles.menu}
|
|
||||||
style={{ left: position.left, top: position.top }}
|
|
||||||
role="menu"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${styles.item} ${activeAction === "sync" ? styles.itemLoading : ""}`}
|
|
||||||
role="menuitem"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => void handleSyncMetadata()}
|
|
||||||
>
|
|
||||||
{activeAction === "sync" ? t("contextMenu.syncingMetadata") : t("contextMenu.syncMetadata")}
|
|
||||||
</button>
|
|
||||||
<div className={styles.separator} role="separator" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${styles.item} ${styles.itemDanger} ${activeAction === "remove" ? styles.itemLoading : ""}`}
|
|
||||||
role="menuitem"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => void handleRemove()}
|
|
||||||
>
|
|
||||||
{activeAction === "remove" ? t("contextMenu.removing") : t("contextMenu.remove")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<h3>Add to Page {page}</h3>
|
||||||
|
{locationText && (
|
||||||
|
<blockquote className="annotation-quote">
|
||||||
|
“{locationText}”
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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">·</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">
|
||||||
|
“{entry.location_text}”
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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">
|
||||||
|
“{bookmark.location_text}”
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user