Archived
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26c5f6f06b | ||
|
|
22ded87250 | ||
|
|
84c0497f21 | ||
|
|
724eae1142 | ||
|
|
654cfec147 | ||
|
|
f989b144cf | ||
|
|
f091386974 | ||
|
|
d9c66e68a6 | ||
|
|
054e7689bd | ||
|
|
6b4c0c43f8 | ||
|
|
730c748f5f | ||
|
|
7cdc3d0969 | ||
|
|
a7864bf5cd | ||
|
|
edac7cb08a | ||
|
|
74fdae61db | ||
|
|
3f626259e8 | ||
|
|
ade810a013 | ||
|
|
6a223d7237 | ||
|
|
656d20879f | ||
|
|
2eeb3fb7d6 | ||
|
|
658f77a746 | ||
|
|
b29ea8211c | ||
|
|
fa82fab44a | ||
|
|
670101b61e | ||
|
|
332b539880 | ||
|
|
f84a593c5d | ||
|
|
3b5b301e42 |
@@ -0,0 +1,10 @@
|
|||||||
|
POSTGRES_DB=postres
|
||||||
|
POSTGRES_USER=root
|
||||||
|
POSTGRES_PASSWORD=root
|
||||||
|
|
||||||
|
DJANGO_SECRET_KEY=changeme
|
||||||
|
DJANGO_DEBUG=True
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
VITE_API_URL=http://localhost:8000/api
|
||||||
+37
-31
@@ -1,34 +1,40 @@
|
|||||||
# Python
|
node_modules
|
||||||
__pycache__/
|
dist
|
||||||
*.py[cod]
|
frontend/dist
|
||||||
*.egg-info/
|
frontend/dist/assets
|
||||||
dist/
|
frontend/dist/assets/index.html
|
||||||
*.egg
|
frontend/dist/assets/index.html.gz
|
||||||
.venv/
|
frontend/dist/assets/index.html.br
|
||||||
venv/
|
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
|
*.sqlite3
|
||||||
|
*.log
|
||||||
# Node
|
*.env
|
||||||
node_modules/
|
*.DS_Store
|
||||||
dist/
|
*.vscode
|
||||||
build/
|
*.idea
|
||||||
.yarn/
|
|
||||||
yarn-error.log
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Environment
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.production
|
|
||||||
|
|
||||||
# Build artifacts (generated)
|
|
||||||
frontend/dist/
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# AGENTS.md — Cloud Reader monorepo
|
||||||
|
|
||||||
|
Guidance for AI agents and contributors working in this repository.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
cloud-reader/
|
||||||
|
├── backend/ # Django REST API (canonical backend)
|
||||||
|
├── frontend/ # React + Vite + TypeScript (web)
|
||||||
|
├── mobile/ # Expo React Native app
|
||||||
|
├── packages/shared/ # @cloud-reader/shared types & utils
|
||||||
|
├── docs/ # Feature specs and architecture notes
|
||||||
|
└── docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Backend** is the source of truth for API contracts, auth, and persistence.
|
||||||
|
- **Frontend** and **mobile** consume the same REST API; share domain types via `@cloud-reader/shared` where practical.
|
||||||
|
- Do not reintroduce removed `api/` or `web/` directories.
|
||||||
|
|
||||||
|
## Documentation conventions
|
||||||
|
|
||||||
|
| Location | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `docs/NNN-*.md` | Cross-cutting or product specs (e.g. `001-customizable-mobile-reading-experience.md`) |
|
||||||
|
| `docs/backend/NNN-*.md` | Backend feature specs; use next sequential number (currently `010`) |
|
||||||
|
| `docs/mobile/NNN-*.md` | Mobile (Expo) feature specs; use next sequential number (currently `010`) |
|
||||||
|
| `docs/frontend/*.md` | Frontend-specific specs |
|
||||||
|
|
||||||
|
When adding a **major backend feature**:
|
||||||
|
|
||||||
|
1. Implement in `backend/apps/<app>/`.
|
||||||
|
2. Add or update a numbered spec under `docs/backend/`.
|
||||||
|
3. Include: objective, API contracts, models/services touched, env vars, and verification steps.
|
||||||
|
|
||||||
|
Do not edit plan files in `.cursor/plans/` unless explicitly asked.
|
||||||
|
|
||||||
|
## Backend (Django)
|
||||||
|
|
||||||
|
- **Python:** 3.12+, managed with `uv` (`backend/pyproject.toml`, `backend/uv.lock`).
|
||||||
|
- **Settings:** `backend/config/settings.py` (pydantic-settings) + `backend/config/django.py`.
|
||||||
|
- **Apps:** `users`, `books`, `annotations`, `reader`.
|
||||||
|
- **Auth:** JWT via `djangorestframework-simplejwt`; register/login at `/api/auth/`.
|
||||||
|
- **Default permission:** `IsAuthenticated` — public endpoints must set `AllowAny` explicitly.
|
||||||
|
- **URL routing:** Single `DefaultRouter` in `books/urls.py`; register specific prefixes (e.g. `ebooks`) **before** the empty `""` book route to avoid `{pk}` shadowing.
|
||||||
|
- **Services:** Put non-trivial logic in `apps/<app>/services/` (not in views/serializers).
|
||||||
|
- **Migrations:** Run after model changes; prefer reusing existing JSON fields (e.g. `EBook.metadata_json`) before new columns.
|
||||||
|
- **Tests:** Only add when requested or when they cover non-obvious behavior.
|
||||||
|
|
||||||
|
### Books domain
|
||||||
|
|
||||||
|
- **`Book`:** Catalog/discovery entity (genres, reading status).
|
||||||
|
- **`EBook`:** Per-user uploaded file (EPUB/PDF); primary import path via `POST /api/books/ebooks/`.
|
||||||
|
- **`metadata_json` on EBook:** External metadata (Open Library, etc.).
|
||||||
|
- **`cover_image` on EBook:** Stored file; library UI reads it from list/detail serializers.
|
||||||
|
|
||||||
|
## Frontend (web)
|
||||||
|
|
||||||
|
- **Entry:** `frontend/src/main.tsx` mounts `App.tsx` (React Router + auth).
|
||||||
|
- **API client:** `frontend/src/api/client.ts` (axios, JWT refresh, base URL `/api`).
|
||||||
|
- **Auth routes:** `/auth` (not `/login`).
|
||||||
|
- **Imports:** Upload via `booksApi.uploadEBook`; library lists user `EBook`s.
|
||||||
|
- **Styling:** Mix of inline styles and CSS modules; match surrounding patterns.
|
||||||
|
- Do not add tests or new `.md` files unless requested.
|
||||||
|
|
||||||
|
## Mobile (Expo)
|
||||||
|
|
||||||
|
- Uses `@cloud-reader/shared` and mirrors web API patterns.
|
||||||
|
- Token storage: AsyncStorage; base URL from `EXPO_PUBLIC_API_URL`.
|
||||||
|
|
||||||
|
## Shared package
|
||||||
|
|
||||||
|
- `packages/shared/src/types.ts` — domain types for web/mobile.
|
||||||
|
- `packages/shared/src/utils.ts` — API endpoint constants, helpers.
|
||||||
|
- Keep camelCase in TS; backend JSON may use snake_case (DRF default).
|
||||||
|
|
||||||
|
## Code change principles
|
||||||
|
|
||||||
|
1. **Minimize scope** — smallest correct diff; no drive-by refactors.
|
||||||
|
2. **Match conventions** — read neighboring code before adding new patterns.
|
||||||
|
3. **No over-engineering** — no extra abstractions for one-off use.
|
||||||
|
4. **Comments** — only for non-obvious business logic.
|
||||||
|
5. **Imports** — remove unused imports; delete dead code after refactors.
|
||||||
|
6. **Secrets** — never commit `.env`; document vars in `.env.example` only.
|
||||||
|
|
||||||
|
## Git & PRs
|
||||||
|
|
||||||
|
- Commit only when the user asks.
|
||||||
|
- Do not force-push `main`/`master`.
|
||||||
|
- Use `gh` for GitHub PRs when requested.
|
||||||
|
|
||||||
|
## Postman
|
||||||
|
|
||||||
|
- Use camelCase operation names (`listSomething`, `createSomething`).
|
||||||
|
- Ask which env vars the user uses; output JSON to copy, not a new file.
|
||||||
|
|
||||||
|
## Commands (run locally when needed)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend && uv sync && uv run python manage.py migrate && uv run python manage.py runserver
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend && yarn install && yarn dev
|
||||||
|
```
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Cloud Reader
|
||||||
|
|
||||||
|
A full-stack e-book reader application with cross-device sync. Upload EPUB/PDF files, track reading progress, bookmark passages, take notes, and customize your reading experience.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
cloud-reader/
|
||||||
|
├── backend/ # Django REST API (canonical backend)
|
||||||
|
│ ├── config/ # Django project settings
|
||||||
|
│ ├── apps/
|
||||||
|
│ │ ├── users/ # User auth (JWT)
|
||||||
|
│ │ ├── books/ # Books, e-books, reading progress, settings
|
||||||
|
│ │ └── annotations/ # Bookmarks and notes
|
||||||
|
│ ├── manage.py
|
||||||
|
│ └── requirements.txt
|
||||||
|
├── frontend/ # React + Vite + TypeScript (web frontend)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── api/ # API client (axios with JWT refresh)
|
||||||
|
│ │ ├── components/ # Reusable components
|
||||||
|
│ │ ├── context/ # Auth and annotations context
|
||||||
|
│ │ ├── hooks/ # Custom hooks
|
||||||
|
│ │ ├── pages/ # Route pages (Library, Reader, AddBook, Auth, Settings)
|
||||||
|
│ │ └── types/ # TypeScript type definitions
|
||||||
|
│ └── package.json
|
||||||
|
├── mobile/ # Expo React Native app (mobile frontend)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── api/ # API client (axios with JWT refresh via AsyncStorage)
|
||||||
|
│ │ ├── components/ # Reusable UI components
|
||||||
|
│ │ ├── context/ # Auth context
|
||||||
|
│ │ ├── hooks/ # Custom hooks
|
||||||
|
│ │ ├── navigation/ # React Navigation (Auth stack + Main tabs)
|
||||||
|
│ │ ├── screens/ # Screen-level components (Login, Library, etc.)
|
||||||
|
│ │ └── types/ # Mobile-specific types
|
||||||
|
│ ├── App.tsx
|
||||||
|
│ └── app.json
|
||||||
|
├── packages/
|
||||||
|
│ └── shared/ # @cloud-reader/shared — domain types & utilities
|
||||||
|
│ └── src/
|
||||||
|
│ ├── types.ts # Shared domain types (Book, User, Bookmark, Note, etc.)
|
||||||
|
│ └── utils.ts # Date formatting, validation, API endpoint constants
|
||||||
|
├── package.json # Root — yarn workspaces config
|
||||||
|
└── docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Docker (recommended)
|
||||||
|
```bash
|
||||||
|
cp env.example .env
|
||||||
|
```
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
- **Frontend:** http://localhost:5173
|
||||||
|
- **Backend API:** http://localhost:8000/api/
|
||||||
|
|
||||||
|
### Backend (standalone)
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python manage.py migrate
|
||||||
|
python manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (standalone)
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
yarn install
|
||||||
|
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
|
||||||
|
Consolidated from duplicate `api/` + `web/` into single `backend/` + `frontend/` canonical structure.
|
||||||
|
- `backend/` kept as canonical; `api/` features (e-book uploads, reading progress, reading settings) merged in.
|
||||||
|
- `frontend/` kept as canonical; `web/` pages (Library, Reader, AddBook, Auth, Settings) merged in.
|
||||||
|
- `api/` and `web/` directories removed.
|
||||||
|
- `mobile/` added as Expo React Native app with shared `@cloud-reader/shared` package.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Backend environment (example – never commit real secrets)
|
||||||
|
DJANGO_SECRET_KEY=django-insecure-change-me-in-production
|
||||||
|
DJANGO_DEBUG=True
|
||||||
|
DB_NAME=cloud_reader
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=postgres
|
||||||
|
DB_HOST=localhost
|
||||||
|
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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpq-dev gcc && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . ./
|
||||||
|
|
||||||
|
RUN mkdir -p media
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.annotations.models import Bookmark, Note
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Bookmark)
|
||||||
|
class BookmarkAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"user",
|
||||||
|
"ebook",
|
||||||
|
"chapter_index",
|
||||||
|
"chapter_title",
|
||||||
|
"page",
|
||||||
|
"highlight_color",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
list_select_related = ("user", "ebook")
|
||||||
|
search_fields = ("user__email", "ebook__title", "location_text", "content")
|
||||||
|
list_filter = ("created_at",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Note)
|
||||||
|
class NoteAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "book", "page", "created_at", "updated_at")
|
||||||
|
list_select_related = ("user", "book")
|
||||||
|
search_fields = ("user__email", "book__title", "content", "location_text")
|
||||||
|
list_filter = ("created_at",)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AnnotationsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.annotations"
|
||||||
|
label = "annotations"
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2026-06-03 22:27
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('books', '0002_ebook_file_size_ebook_format_ebook_metadata_json_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Note',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('page', models.PositiveIntegerField()),
|
||||||
|
('location_text', models.TextField(blank=True, default='', help_text='The selected passage text this note refers to')),
|
||||||
|
('content', models.TextField(help_text='The note body content')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='books.book')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Note',
|
||||||
|
'verbose_name_plural': 'Notes',
|
||||||
|
'db_table': 'annotations_note',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Bookmark',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('page', models.PositiveIntegerField()),
|
||||||
|
('location_text', models.TextField(blank=True, default='', help_text='The selected passage text at this location')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to='books.book')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Bookmark',
|
||||||
|
'verbose_name_plural': 'Bookmarks',
|
||||||
|
'db_table': 'annotations_bookmark',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'constraints': [models.UniqueConstraint(fields=('user', 'book', 'page'), name='uq_bookmark_user_book_page')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
def clear_legacy_bookmarks(apps, schema_editor):
|
||||||
|
Bookmark = apps.get_model("annotations", "Bookmark")
|
||||||
|
Bookmark.objects.all().delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("books", "0003_readingprogress_epub_location"),
|
||||||
|
("annotations", "0001_initial"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(clear_legacy_bookmarks, migrations.RunPython.noop),
|
||||||
|
migrations.RemoveConstraint(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="uq_bookmark_user_book_page",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="book",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="ebook",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="bookmarks",
|
||||||
|
to="books.ebook",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="epub_cfi",
|
||||||
|
field=models.CharField(db_index=True, default="", max_length=2048),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="chapter_index",
|
||||||
|
field=models.PositiveIntegerField(db_index=True, default=0),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="chapter_title",
|
||||||
|
field=models.CharField(blank=True, default="", max_length=512),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="content",
|
||||||
|
field=models.TextField(blank=True, default=""),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="page",
|
||||||
|
field=models.PositiveIntegerField(
|
||||||
|
default=1,
|
||||||
|
help_text="Legacy/display page; derived from chapter_index + 1",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="bookmark",
|
||||||
|
options={
|
||||||
|
"ordering": ["chapter_index", "epub_cfi"],
|
||||||
|
"verbose_name": "Bookmark",
|
||||||
|
"verbose_name_plural": "Bookmarks",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
# ebook was added nullable for SQLite; enforce NOT NULL via AlterField
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="ebook",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="bookmarks",
|
||||||
|
to="books.ebook",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="bookmark",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("user", "ebook", "epub_cfi"),
|
||||||
|
name="uq_bookmark_user_ebook_cfi",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("annotations", "0002_bookmark_ebook_epub_fields"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="highlight_color",
|
||||||
|
field=models.CharField(default="#fde047", max_length=7),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2026-06-04 03:31
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('annotations', '0003_bookmark_highlight_color'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='bookmark',
|
||||||
|
name='content',
|
||||||
|
field=models.TextField(blank=True, default='', help_text='Optional user thought; empty means bookmark-only'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='bookmark',
|
||||||
|
name='highlight_color',
|
||||||
|
field=models.CharField(default='#fde047', help_text='Hex color for in-book passage highlight (e.g. #fde047)', max_length=7),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Bookmark(models.Model):
|
||||||
|
"""A saved passage anchor in an uploaded ebook (EPUB CFI + optional thought)."""
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="bookmarks",
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
ebook = models.ForeignKey(
|
||||||
|
"books.EBook",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="bookmarks",
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
epub_cfi = models.CharField(max_length=2048, db_index=True)
|
||||||
|
chapter_index = models.PositiveIntegerField(default=0, db_index=True)
|
||||||
|
chapter_title = models.CharField(max_length=512, blank=True, default="")
|
||||||
|
page = models.PositiveIntegerField(
|
||||||
|
default=1,
|
||||||
|
help_text="Legacy/display page; derived from chapter_index + 1",
|
||||||
|
)
|
||||||
|
location_text = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
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)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "annotations_bookmark"
|
||||||
|
verbose_name = "Bookmark"
|
||||||
|
verbose_name_plural = "Bookmarks"
|
||||||
|
ordering = ["chapter_index", "epub_cfi"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "ebook", "epub_cfi"],
|
||||||
|
name="uq_bookmark_user_ebook_cfi",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.user} @ {self.ebook} ch.{self.chapter_index}"
|
||||||
|
|
||||||
|
|
||||||
|
class Note(models.Model):
|
||||||
|
"""Legacy note model; new UX uses Bookmark.content instead."""
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="notes",
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
book = models.ForeignKey(
|
||||||
|
"books.Book",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="notes",
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "annotations_note"
|
||||||
|
verbose_name = "Note"
|
||||||
|
verbose_name_plural = "Notes"
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
preview = self.content[:50]
|
||||||
|
return f"{self.user} @ {self.book} p.{self.page}: {preview}"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from rest_framework import permissions
|
||||||
|
|
||||||
|
|
||||||
|
class IsOwner(permissions.BasePermission):
|
||||||
|
"""Grant access only if the requesting user owns the object."""
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj) -> bool:
|
||||||
|
return obj.user == request.user
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
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):
|
||||||
|
ebook_title = serializers.CharField(source="ebook.title", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Bookmark
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"ebook",
|
||||||
|
"ebook_title",
|
||||||
|
"epub_cfi",
|
||||||
|
"chapter_index",
|
||||||
|
"chapter_title",
|
||||||
|
"page",
|
||||||
|
"location_text",
|
||||||
|
"content",
|
||||||
|
"highlight_color",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
read_only_fields = ["id", "created_at", "updated_at", "ebook_title", "page"]
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkCreateSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Bookmark
|
||||||
|
fields = [
|
||||||
|
"ebook",
|
||||||
|
"epub_cfi",
|
||||||
|
"chapter_index",
|
||||||
|
"chapter_title",
|
||||||
|
"location_text",
|
||||||
|
"content",
|
||||||
|
"highlight_color",
|
||||||
|
]
|
||||||
|
|
||||||
|
def validate_highlight_color(self, value: str) -> str:
|
||||||
|
return validate_highlight_color(value)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
user = self.context["request"].user
|
||||||
|
ebook = attrs["ebook"]
|
||||||
|
epub_cfi = attrs["epub_cfi"]
|
||||||
|
if Bookmark.objects.filter(user=user, ebook=ebook, epub_cfi=epub_cfi).exists():
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"epub_cfi": "A marker already exists for this passage."}
|
||||||
|
)
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class NoteSerializer(serializers.ModelSerializer):
|
||||||
|
book_title = serializers.CharField(source="book.title", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Note
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"book",
|
||||||
|
"book_title",
|
||||||
|
"page",
|
||||||
|
"location_text",
|
||||||
|
"content",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
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 NoteCreateSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Note
|
||||||
|
fields = ["book", "page", "location_text", "content"]
|
||||||
|
|
||||||
|
def validate_page(self, value: int) -> int:
|
||||||
|
if value < 1:
|
||||||
|
raise serializers.ValidationError("Page must be a positive integer.")
|
||||||
|
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
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
validated_data["user"] = self.context["request"].user
|
||||||
|
return super().create(validated_data)
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""Tests for the annotations app – Bookmarks & Notes API."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from apps.annotations.models import Bookmark, Note
|
||||||
|
from apps.books.models import Book, EBook
|
||||||
|
from apps.users.models import User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def api_client() -> APIClient:
|
||||||
|
return APIClient()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user() -> User:
|
||||||
|
return User.objects.create_user(
|
||||||
|
username="testuser",
|
||||||
|
email="test@example.com",
|
||||||
|
password="testpass123",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def other_user() -> User:
|
||||||
|
return User.objects.create_user(
|
||||||
|
username="other",
|
||||||
|
email="other@example.com",
|
||||||
|
password="testpass123",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_client(api_client: APIClient, user: User) -> APIClient:
|
||||||
|
api_client.force_authenticate(user=user)
|
||||||
|
return api_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def book() -> Book:
|
||||||
|
return Book.objects.create(
|
||||||
|
title="Test Book",
|
||||||
|
author="Test Author",
|
||||||
|
total_pages=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ebook(user: User) -> EBook:
|
||||||
|
return EBook.objects.create(
|
||||||
|
user=user,
|
||||||
|
title="Test Ebook",
|
||||||
|
author="Test Author",
|
||||||
|
format="epub",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def bookmark(auth_client, user: User, ebook: EBook) -> Bookmark:
|
||||||
|
return Bookmark.objects.create(
|
||||||
|
user=user,
|
||||||
|
ebook=ebook,
|
||||||
|
epub_cfi="epubcfi(/6/4!/4/2,/1:0,/1:10)",
|
||||||
|
chapter_index=2,
|
||||||
|
chapter_title="Chapter 3",
|
||||||
|
page=3,
|
||||||
|
location_text="important passage",
|
||||||
|
content="",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def note(auth_client, user: User, book: Book) -> Note:
|
||||||
|
return Note.objects.create(
|
||||||
|
user=user,
|
||||||
|
book=book,
|
||||||
|
page=15,
|
||||||
|
location_text="highlighted section",
|
||||||
|
content="This is my note about this section.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBookmarkList:
|
||||||
|
url = reverse("bookmark-list")
|
||||||
|
|
||||||
|
def test_list_requires_auth(self, api_client: APIClient):
|
||||||
|
response = api_client.get(self.url)
|
||||||
|
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||||
|
|
||||||
|
def test_list_returns_user_bookmarks_only(
|
||||||
|
self, auth_client: APIClient, user: User, other_user: User, ebook: EBook
|
||||||
|
):
|
||||||
|
Bookmark.objects.create(
|
||||||
|
user=user,
|
||||||
|
ebook=ebook,
|
||||||
|
epub_cfi="epubcfi(/6/4!/4/2,/1:0,/1:5)",
|
||||||
|
chapter_index=0,
|
||||||
|
page=1,
|
||||||
|
)
|
||||||
|
other_ebook = EBook.objects.create(user=other_user, title="Other", format="epub")
|
||||||
|
Bookmark.objects.create(
|
||||||
|
user=other_user,
|
||||||
|
ebook=other_ebook,
|
||||||
|
epub_cfi="epubcfi(/6/4!/4/2,/2:0,/2:5)",
|
||||||
|
chapter_index=0,
|
||||||
|
page=1,
|
||||||
|
)
|
||||||
|
response = auth_client.get(self.url)
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
results = response.data["results"]
|
||||||
|
assert len(results) == 1
|
||||||
|
|
||||||
|
def test_filter_by_ebook(self, auth_client: APIClient, bookmark: Bookmark, ebook: EBook):
|
||||||
|
response = auth_client.get(self.url, {"ebook": str(ebook.id)})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert len(response.data["results"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestBookmarkCreate:
|
||||||
|
url = reverse("bookmark-list")
|
||||||
|
|
||||||
|
def test_create_marker(self, auth_client: APIClient, ebook: EBook):
|
||||||
|
data = {
|
||||||
|
"ebook": ebook.id,
|
||||||
|
"epub_cfi": "epubcfi(/6/4!/4/2,/1:0,/1:20)",
|
||||||
|
"chapter_index": 1,
|
||||||
|
"chapter_title": "Chapter 2",
|
||||||
|
"location_text": "Selected text",
|
||||||
|
"content": "My thought",
|
||||||
|
}
|
||||||
|
response = auth_client.post(self.url, data, format="json")
|
||||||
|
assert response.status_code == status.HTTP_201_CREATED
|
||||||
|
assert response.data["content"] == "My thought"
|
||||||
|
assert response.data["ebook"] == ebook.id
|
||||||
|
|
||||||
|
def test_create_bookmark_only_empty_content(self, auth_client: APIClient, ebook: EBook):
|
||||||
|
data = {
|
||||||
|
"ebook": ebook.id,
|
||||||
|
"epub_cfi": "epubcfi(/6/4!/4/2,/3:0,/3:8)",
|
||||||
|
"chapter_index": 0,
|
||||||
|
"location_text": "Quote only",
|
||||||
|
"content": "",
|
||||||
|
}
|
||||||
|
response = auth_client.post(self.url, data, format="json")
|
||||||
|
assert response.status_code == status.HTTP_201_CREATED
|
||||||
|
assert response.data["content"] == ""
|
||||||
|
|
||||||
|
def test_duplicate_cfi_rejected(self, auth_client: APIClient, bookmark: Bookmark, ebook: EBook):
|
||||||
|
data = {
|
||||||
|
"ebook": ebook.id,
|
||||||
|
"epub_cfi": bookmark.epub_cfi,
|
||||||
|
"chapter_index": 0,
|
||||||
|
"location_text": "dup",
|
||||||
|
}
|
||||||
|
response = auth_client.post(self.url, data, format="json")
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
|
|
||||||
|
class TestBookmarkDetail:
|
||||||
|
def test_delete_bookmark(self, auth_client: APIClient, bookmark: Bookmark):
|
||||||
|
url = reverse("bookmark-detail", args=[str(bookmark.id)])
|
||||||
|
response = auth_client.delete(url)
|
||||||
|
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from django.urls import include, path
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from apps.annotations.views import BookmarkViewSet, NoteViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"bookmarks", BookmarkViewSet, basename="bookmark")
|
||||||
|
router.register(r"notes", NoteViewSet, basename="note")
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", include(router.urls)),
|
||||||
|
]
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
from rest_framework import status, viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from apps.annotations.models import Bookmark, Note
|
||||||
|
from apps.annotations.permissions import IsOwner
|
||||||
|
from apps.annotations.serializers import (
|
||||||
|
BookmarkCreateSerializer,
|
||||||
|
BookmarkSerializer,
|
||||||
|
NoteCreateSerializer,
|
||||||
|
NoteSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkViewSet(viewsets.ModelViewSet):
|
||||||
|
"""CRUD for user ebook markers (passage anchors + optional thoughts)."""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated, IsOwner]
|
||||||
|
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||||
|
filterset_fields = ["ebook"]
|
||||||
|
search_fields = ["location_text", "content", "chapter_title"]
|
||||||
|
ordering_fields = ["chapter_index", "epub_cfi", "created_at"]
|
||||||
|
ordering = ["chapter_index", "epub_cfi"]
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == "create":
|
||||||
|
return BookmarkCreateSerializer
|
||||||
|
return BookmarkSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Bookmark.objects.filter(user=self.request.user).select_related("ebook")
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(user=self.request.user)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["delete"], url_path="batch-delete")
|
||||||
|
def batch_delete(self, request):
|
||||||
|
ids = request.data.get("ids", [])
|
||||||
|
if not ids:
|
||||||
|
return Response(
|
||||||
|
{"detail": "No ids provided."}, status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
deleted, _ = Bookmark.objects.filter(
|
||||||
|
id__in=ids, user=request.user
|
||||||
|
).delete()
|
||||||
|
return Response({"deleted": deleted}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class NoteViewSet(viewsets.ModelViewSet):
|
||||||
|
"""Legacy notes API (catalog Book FK)."""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated, IsOwner]
|
||||||
|
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||||
|
filterset_fields = ["book"]
|
||||||
|
search_fields = ["content", "location_text"]
|
||||||
|
ordering_fields = ["created_at", "page"]
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == "create":
|
||||||
|
return NoteCreateSerializer
|
||||||
|
return NoteSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Note.objects.filter(user=self.request.user).select_related("book")
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(user=self.request.user)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["delete"], url_path="batch-delete")
|
||||||
|
def batch_delete(self, request):
|
||||||
|
ids = request.data.get("ids", [])
|
||||||
|
if not ids:
|
||||||
|
return Response(
|
||||||
|
{"detail": "No ids provided."}, status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
deleted, _ = Note.objects.filter(
|
||||||
|
id__in=ids, user=request.user
|
||||||
|
).delete()
|
||||||
|
return Response({"deleted": deleted}, status=status.HTTP_200_OK)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.books.models import Book
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Book)
|
||||||
|
class BookAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("title", "author", "total_pages", "created_at")
|
||||||
|
search_fields = ("title", "author")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BooksConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.books"
|
||||||
|
label = "books"
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2026-05-26 03:33
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Book',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(db_index=True, max_length=512)),
|
||||||
|
('author', models.CharField(blank=True, db_index=True, default='', max_length=256)),
|
||||||
|
('genre', models.CharField(blank=True, db_index=True, default='', max_length=128)),
|
||||||
|
('description', models.TextField(blank=True, default='')),
|
||||||
|
('reading_status', models.CharField(choices=[('want_to_read', 'Want to Read'), ('reading', 'Reading'), ('finished', 'Finished'), ('dnf', 'Did Not Finish')], db_index=True, default='want_to_read', max_length=20)),
|
||||||
|
('total_pages', models.PositiveIntegerField(default=0)),
|
||||||
|
('cover_image', models.URLField(blank=True, default='')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Book',
|
||||||
|
'verbose_name_plural': 'Books',
|
||||||
|
'db_table': 'books_book',
|
||||||
|
'ordering': ['title'],
|
||||||
|
'indexes': [models.Index(fields=['title', 'author', 'genre'], name='books_book_title_9fddc2_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EBook',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=512)),
|
||||||
|
('author', models.CharField(blank=True, default='', max_length=256)),
|
||||||
|
('file', models.FileField(upload_to='ebooks/%Y/%m/%d/')),
|
||||||
|
('cover_image', models.ImageField(blank=True, null=True, upload_to='ebook_covers/%Y/%m/%d/')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ebooks', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'E-Book',
|
||||||
|
'verbose_name_plural': 'E-Books',
|
||||||
|
'db_table': 'books_ebook',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ReadingProgress',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('current_position', models.FloatField(default=0.0)),
|
||||||
|
('last_page', models.IntegerField(default=0)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('ebook', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='reading_progress', to='books.ebook')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reading_progress', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name_plural': 'reading progress',
|
||||||
|
'db_table': 'books_reading_progress',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ReadingSettings',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('font_size', models.IntegerField(default=18)),
|
||||||
|
('font_style', models.CharField(choices=[('sans-serif', 'Sans Serif'), ('serif', 'Serif'), ('monospace', 'Monospace')], default='sans-serif', max_length=20)),
|
||||||
|
('background_color', models.CharField(choices=[('#ffffff', 'White'), ('#f4e4c1', 'Sepia'), ('#1a1a2e', 'Dark'), ('#c7edcc', 'Green')], default='#ffffff', max_length=7)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='reading_settings', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name_plural': 'reading settings',
|
||||||
|
'db_table': 'books_reading_settings',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='ebook',
|
||||||
|
index=models.Index(fields=['user', '-created_at'], name='books_ebook_user_id_0b6bdb_idx'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='readingprogress',
|
||||||
|
unique_together={('user', 'ebook')},
|
||||||
|
),
|
||||||
|
]
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2026-06-03 22:21
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('books', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='ebook',
|
||||||
|
name='file_size',
|
||||||
|
field=models.BigIntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='ebook',
|
||||||
|
name='format',
|
||||||
|
field=models.CharField(blank=True, default='', editable=False, max_length=20),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='ebook',
|
||||||
|
name='metadata_json',
|
||||||
|
field=models.JSONField(blank=True, default=dict),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='ebook',
|
||||||
|
name='page_count',
|
||||||
|
field=models.PositiveIntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='readingprogress',
|
||||||
|
name='device_id',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=128),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='readingprogress',
|
||||||
|
name='device_name',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=128),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='readingprogress',
|
||||||
|
name='version',
|
||||||
|
field=models.PositiveIntegerField(default=1),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='ebook',
|
||||||
|
name='author',
|
||||||
|
field=models.CharField(blank=True, db_index=True, default='', max_length=256),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='ebook',
|
||||||
|
name='title',
|
||||||
|
field=models.CharField(db_index=True, max_length=512),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='BookChapter',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=512)),
|
||||||
|
('index', models.IntegerField(default=0)),
|
||||||
|
('href', models.CharField(blank=True, default='', max_length=1024)),
|
||||||
|
('children', models.JSONField(blank=True, default=list)),
|
||||||
|
('ebook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chapters', to='books.ebook')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Book Chapter',
|
||||||
|
'verbose_name_plural': 'Book Chapters',
|
||||||
|
'db_table': 'books_book_chapter',
|
||||||
|
'ordering': ['index'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DownloadRecord',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('file_size', models.BigIntegerField(default=0)),
|
||||||
|
('downloaded_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('ebook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='download_records', to='books.ebook')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='download_records', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Download Record',
|
||||||
|
'verbose_name_plural': 'Download Records',
|
||||||
|
'db_table': 'books_download_record',
|
||||||
|
'ordering': ['-downloaded_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='ReadingSettings',
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='bookchapter',
|
||||||
|
index=models.Index(fields=['ebook', 'index'], name='books_book__ebook_i_464cd6_idx'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='downloadrecord',
|
||||||
|
unique_together={('user', 'ebook')},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("books", "0002_ebook_file_size_ebook_format_ebook_metadata_json_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="readingprogress",
|
||||||
|
name="epub_location",
|
||||||
|
field=models.CharField(blank=True, default="", max_length=2048),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models.signals import post_delete
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class ReadingStatus(models.TextChoices):
|
||||||
|
WANT_TO_READ = "want_to_read", "Want to Read"
|
||||||
|
READING = "reading", "Reading"
|
||||||
|
FINISHED = "finished", "Finished"
|
||||||
|
DNF = "dnf", "Did Not Finish"
|
||||||
|
|
||||||
|
|
||||||
|
class Book(models.Model):
|
||||||
|
title = models.CharField(max_length=512, db_index=True)
|
||||||
|
author = models.CharField(max_length=256, blank=True, default="", db_index=True)
|
||||||
|
genre = models.CharField(max_length=128, blank=True, default="", db_index=True)
|
||||||
|
description = models.TextField(blank=True, default="")
|
||||||
|
reading_status = models.CharField(max_length=20, choices=ReadingStatus.choices, default=ReadingStatus.WANT_TO_READ, db_index=True)
|
||||||
|
total_pages = models.PositiveIntegerField(default=0)
|
||||||
|
cover_image = models.URLField(blank=True, default="")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "books_book"
|
||||||
|
verbose_name = "Book"
|
||||||
|
verbose_name_plural = "Books"
|
||||||
|
ordering = ["title"]
|
||||||
|
indexes = [models.Index(fields=["title", "author", "genre"])]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
|
class EBook(models.Model):
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ebooks")
|
||||||
|
title = models.CharField(max_length=512, db_index=True)
|
||||||
|
author = models.CharField(max_length=256, blank=True, default="", db_index=True)
|
||||||
|
format = models.CharField(max_length=20, blank=True, default="", editable=False)
|
||||||
|
page_count = models.PositiveIntegerField(default=0)
|
||||||
|
file_size = models.BigIntegerField(default=0)
|
||||||
|
metadata_json = models.JSONField(blank=True, default=dict)
|
||||||
|
file = models.FileField(upload_to="ebooks/%Y/%m/%d/")
|
||||||
|
cover_image = models.ImageField(upload_to="ebook_covers/%Y/%m/%d/", blank=True, null=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "books_ebook"
|
||||||
|
verbose_name = "E-Book"
|
||||||
|
verbose_name_plural = "E-Books"
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
indexes = [models.Index(fields=["user", "-created_at"])]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
def filename(self):
|
||||||
|
return Path(self.file.name).name if self.file else ""
|
||||||
|
|
||||||
|
|
||||||
|
class BookChapter(models.Model):
|
||||||
|
ebook = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name="chapters")
|
||||||
|
title = models.CharField(max_length=512)
|
||||||
|
index = models.IntegerField(default=0)
|
||||||
|
href = models.CharField(max_length=1024, blank=True, default="")
|
||||||
|
children = models.JSONField(blank=True, default=list)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "books_book_chapter"
|
||||||
|
verbose_name = "Book Chapter"
|
||||||
|
verbose_name_plural = "Book Chapters"
|
||||||
|
ordering = ["index"]
|
||||||
|
indexes = [models.Index(fields=["ebook", "index"])]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.ebook.title} - {self.title}"
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_delete, sender=EBook)
|
||||||
|
def _auto_delete_ebook_file(sender, instance, **kwargs):
|
||||||
|
if instance.file:
|
||||||
|
instance.file.delete(save=False)
|
||||||
|
if instance.cover_image:
|
||||||
|
instance.cover_image.delete(save=False)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadRecord(models.Model):
|
||||||
|
"""Tracks book downloads for offline access management."""
|
||||||
|
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="download_records")
|
||||||
|
ebook = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name="download_records")
|
||||||
|
file_size = models.BigIntegerField(default=0)
|
||||||
|
downloaded_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "books_download_record"
|
||||||
|
verbose_name = "Download Record"
|
||||||
|
verbose_name_plural = "Download Records"
|
||||||
|
ordering = ["-downloaded_at"]
|
||||||
|
unique_together = [("user", "ebook")]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user} - {self.ebook.title}"
|
||||||
|
|
||||||
|
|
||||||
|
class ReadingProgress(models.Model):
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reading_progress")
|
||||||
|
ebook = models.OneToOneField(EBook, on_delete=models.CASCADE, related_name="reading_progress")
|
||||||
|
current_position = models.FloatField(default=0.0)
|
||||||
|
last_page = models.IntegerField(default=0)
|
||||||
|
epub_location = models.CharField(max_length=2048, blank=True, default="")
|
||||||
|
device_id = models.CharField(max_length=128, blank=True, default="")
|
||||||
|
device_name = models.CharField(max_length=128, blank=True, default="")
|
||||||
|
version = models.PositiveIntegerField(default=1)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "books_reading_progress"
|
||||||
|
verbose_name_plural = "reading progress"
|
||||||
|
unique_together = [("user", "ebook")]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.ebook.title} - {self.current_position:.1f}%"
|
||||||
|
|
||||||
|
def update_with_sync(self, position: float, last_page: int,
|
||||||
|
device_id: str, device_name: str,
|
||||||
|
client_updated_at: str | None = None) -> tuple["ReadingProgress", bool]:
|
||||||
|
"""Update progress with conflict resolution (last-write-wins by timestamp).
|
||||||
|
|
||||||
|
Returns (instance, applied) where applied is True if the update was applied.
|
||||||
|
"""
|
||||||
|
if client_updated_at and self.updated_at:
|
||||||
|
try:
|
||||||
|
from django.utils.timezone import is_naive, make_aware
|
||||||
|
from datetime import datetime
|
||||||
|
client_dt = datetime.fromisoformat(client_updated_at.replace("Z", "+00:00"))
|
||||||
|
if is_naive(client_dt):
|
||||||
|
client_dt = make_aware(client_dt)
|
||||||
|
if client_dt <= self.updated_at:
|
||||||
|
return self, False
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.current_position = position
|
||||||
|
self.last_page = last_page
|
||||||
|
self.device_id = device_id
|
||||||
|
self.device_name = device_name
|
||||||
|
self.version += 1
|
||||||
|
self.save(update_fields=[
|
||||||
|
"current_position", "last_page",
|
||||||
|
"device_id", "device_name", "version", "updated_at",
|
||||||
|
])
|
||||||
|
return self, True
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from apps.books.models import Book, BookChapter, EBook, ReadingProgress, ReadingStatus, DownloadRecord
|
||||||
|
from apps.books.services.ebook_metadata import subjects_from_ebook
|
||||||
|
from apps.books.services.metadata import enrich_ebook_metadata
|
||||||
|
from apps.books.services.process_ebook import apply_processing_to_ebook
|
||||||
|
from apps.reader.models import ReadingSettings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class BookReadingSettingsSerializer(serializers.ModelSerializer):
|
||||||
|
font_style = serializers.CharField(source="font_family")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ReadingSettings
|
||||||
|
fields = ["font_size", "font_style", "background_color"]
|
||||||
|
|
||||||
|
def validate_font_size(self, value: int) -> int:
|
||||||
|
if value < 12 or value > 36:
|
||||||
|
raise serializers.ValidationError("Font size must be between 12 and 36.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class BookChapterSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = BookChapter
|
||||||
|
fields = ["id", "title", "index", "href", "children"]
|
||||||
|
|
||||||
|
|
||||||
|
class EBookContentSerializer(serializers.Serializer):
|
||||||
|
page = serializers.IntegerField()
|
||||||
|
total_pages = serializers.IntegerField()
|
||||||
|
content = serializers.CharField()
|
||||||
|
chapter_title = serializers.CharField()
|
||||||
|
format = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class EBookTocSerializer(serializers.Serializer):
|
||||||
|
chapters = serializers.ListField(child=BookChapterSerializer())
|
||||||
|
format = serializers.CharField()
|
||||||
|
page_count = serializers.IntegerField()
|
||||||
|
|
||||||
|
|
||||||
|
class BookListSerializer(serializers.ModelSerializer):
|
||||||
|
reading_status_display = serializers.CharField(source="get_reading_status_display", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Book
|
||||||
|
fields = ["id", "title", "author", "genre", "reading_status", "reading_status_display", "cover_image"]
|
||||||
|
|
||||||
|
|
||||||
|
class BookDetailSerializer(serializers.ModelSerializer):
|
||||||
|
reading_status_display = serializers.CharField(source="get_reading_status_display", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Book
|
||||||
|
fields = ["id", "title", "author", "genre", "description", "reading_status", "reading_status_display", "cover_image", "total_pages", "created_at", "updated_at"]
|
||||||
|
read_only_fields = ["id", "created_at", "updated_at"]
|
||||||
|
|
||||||
|
|
||||||
|
class BookSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Book
|
||||||
|
fields = ["id", "title", "author", "genre", "description", "reading_status", "cover_image", "total_pages", "created_at", "updated_at"]
|
||||||
|
read_only_fields = ["id", "created_at", "updated_at"]
|
||||||
|
|
||||||
|
|
||||||
|
class EBookListSerializer(serializers.ModelSerializer):
|
||||||
|
filename = serializers.CharField(read_only=True)
|
||||||
|
format = serializers.CharField(read_only=True)
|
||||||
|
progress = serializers.SerializerMethodField()
|
||||||
|
started = serializers.SerializerMethodField()
|
||||||
|
subjects = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = EBook
|
||||||
|
fields = [
|
||||||
|
"id", "title", "author", "filename", "format", "page_count", "file_size",
|
||||||
|
"cover_image", "created_at", "progress", "started", "subjects",
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_subjects(self, obj: EBook) -> list[str]:
|
||||||
|
return subjects_from_ebook(obj)
|
||||||
|
|
||||||
|
def get_progress(self, obj):
|
||||||
|
try:
|
||||||
|
return obj.reading_progress.current_position
|
||||||
|
except ReadingProgress.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_started(self, obj):
|
||||||
|
"""True when the user has opened the reader with saved progress."""
|
||||||
|
try:
|
||||||
|
rp = obj.reading_progress
|
||||||
|
except ReadingProgress.DoesNotExist:
|
||||||
|
return False
|
||||||
|
if rp.current_position >= 99:
|
||||||
|
return False
|
||||||
|
if obj.format == "pdf":
|
||||||
|
return rp.last_page > 0 or rp.current_position > 0
|
||||||
|
return bool((rp.epub_location or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
class EBookDetailSerializer(serializers.ModelSerializer):
|
||||||
|
filename = serializers.CharField(read_only=True)
|
||||||
|
format = serializers.CharField(read_only=True)
|
||||||
|
file_url = serializers.SerializerMethodField()
|
||||||
|
progress = serializers.SerializerMethodField()
|
||||||
|
metadata = serializers.JSONField(source="metadata_json", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = EBook
|
||||||
|
fields = [
|
||||||
|
"id", "title", "author", "filename", "format", "page_count", "file_size",
|
||||||
|
"file_url", "cover_image", "metadata", "created_at", "updated_at", "progress",
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_file_url(self, obj):
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request and obj.file:
|
||||||
|
return request.build_absolute_uri(obj.file.url)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_progress(self, obj):
|
||||||
|
try:
|
||||||
|
rp = obj.reading_progress
|
||||||
|
return {
|
||||||
|
"current_position": rp.current_position,
|
||||||
|
"last_page": rp.last_page,
|
||||||
|
"epub_location": rp.epub_location,
|
||||||
|
}
|
||||||
|
except ReadingProgress.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class EBookUploadSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = EBook
|
||||||
|
fields = ["title", "author", "file", "cover_image"]
|
||||||
|
extra_kwargs = {"title": {"required": True}, "file": {"required": True}}
|
||||||
|
|
||||||
|
def validate_file(self, value):
|
||||||
|
import os
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
ext = os.path.splitext(str(getattr(value, "name", "")))[1].lower()
|
||||||
|
if ext not in (".epub", ".pdf"):
|
||||||
|
raise serializers.ValidationError("Only EPUB and PDF files are supported.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
import os
|
||||||
|
|
||||||
|
validated_data["user"] = self.context["request"].user
|
||||||
|
name = str(getattr(validated_data.get("file"), "name", ""))
|
||||||
|
ext = os.path.splitext(name)[1].lower().lstrip(".")
|
||||||
|
if ext:
|
||||||
|
validated_data["format"] = ext
|
||||||
|
ebook = super().create(validated_data)
|
||||||
|
try:
|
||||||
|
apply_processing_to_ebook(ebook)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("E-book processing failed for ebook %s", ebook.pk)
|
||||||
|
try:
|
||||||
|
enrich_ebook_metadata(ebook)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Metadata enrichment failed for ebook %s", ebook.pk)
|
||||||
|
ebook.refresh_from_db()
|
||||||
|
return ebook
|
||||||
|
|
||||||
|
|
||||||
|
class ReadingProgressSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ReadingProgress
|
||||||
|
fields = [
|
||||||
|
"current_position", "last_page", "epub_location",
|
||||||
|
"device_id", "device_name", "version", "updated_at",
|
||||||
|
]
|
||||||
|
read_only_fields = ["version", "updated_at"]
|
||||||
|
extra_kwargs = {"current_position": {"required": False, "min_value": 0.0, "max_value": 100.0}}
|
||||||
|
|
||||||
|
def validate_current_position(self, value):
|
||||||
|
if value < 0.0 or value > 100.0:
|
||||||
|
raise serializers.ValidationError("Position must be between 0.0 and 100.0.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadRecordSerializer(serializers.ModelSerializer):
|
||||||
|
ebook_id = serializers.IntegerField(source="ebook.id", read_only=True)
|
||||||
|
ebook_title = serializers.CharField(source="ebook.title", read_only=True)
|
||||||
|
author = serializers.CharField(source="ebook.author", read_only=True)
|
||||||
|
filename = serializers.SerializerMethodField()
|
||||||
|
cover_image = serializers.ImageField(source="ebook.cover_image", read_only=True)
|
||||||
|
format = serializers.CharField(source="ebook.format", read_only=True)
|
||||||
|
progress = serializers.SerializerMethodField()
|
||||||
|
file_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DownloadRecord
|
||||||
|
fields = [
|
||||||
|
"id", "ebook_id", "ebook_title", "author", "filename", "file_url",
|
||||||
|
"file_size", "cover_image", "format", "downloaded_at", "progress",
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_filename(self, obj):
|
||||||
|
return obj.ebook.filename()
|
||||||
|
|
||||||
|
def get_file_url(self, obj):
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request and obj.ebook.file:
|
||||||
|
return request.build_absolute_uri(obj.ebook.file.url)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_progress(self, obj):
|
||||||
|
try:
|
||||||
|
rp = obj.ebook.reading_progress
|
||||||
|
return {
|
||||||
|
"current_position": rp.current_position,
|
||||||
|
"last_page": rp.last_page,
|
||||||
|
"epub_location": rp.epub_location,
|
||||||
|
}
|
||||||
|
except ReadingProgress.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class StorageSummarySerializer(serializers.Serializer):
|
||||||
|
total_downloads = serializers.IntegerField()
|
||||||
|
total_size_bytes = serializers.IntegerField()
|
||||||
|
ebooks = serializers.ListField(child=serializers.DictField())
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from apps.books.models import EBook
|
||||||
|
|
||||||
|
|
||||||
|
def subjects_from_ebook(ebook: EBook) -> list[str]:
|
||||||
|
ol = (ebook.metadata_json or {}).get("openlibrary") or {}
|
||||||
|
return [s.strip() for s in (ol.get("subjects") or []) if isinstance(s, str) and s.strip()]
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
|
from apps.books.models import EBook
|
||||||
|
from apps.books.services.openlibrary import (
|
||||||
|
HIGH_CONFIDENCE,
|
||||||
|
download_cover,
|
||||||
|
fetch_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_ebook_metadata(ebook: EBook) -> EBook:
|
||||||
|
"""Fetch Open Library metadata and update the ebook. Never raises to callers."""
|
||||||
|
user_title = ebook.title
|
||||||
|
user_author = ebook.author or ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = fetch_metadata(user_title, user_author)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Open Library metadata fetch failed for ebook %s", ebook.pk)
|
||||||
|
return ebook
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
return ebook
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
metadata = {
|
||||||
|
"source": "openlibrary",
|
||||||
|
"matched_at": now,
|
||||||
|
"match_language": result.match_language,
|
||||||
|
"match_score": result.match_score,
|
||||||
|
"match_status": result.match_status,
|
||||||
|
"user_input": {"title": user_title, "author": user_author},
|
||||||
|
"openlibrary": result.openlibrary,
|
||||||
|
}
|
||||||
|
ebook.metadata_json = metadata
|
||||||
|
|
||||||
|
update_fields = ["metadata_json", "updated_at"]
|
||||||
|
|
||||||
|
if result.match_status == "not_found":
|
||||||
|
ebook.save(update_fields=update_fields)
|
||||||
|
return ebook
|
||||||
|
|
||||||
|
ol = result.openlibrary
|
||||||
|
cover_id = ol.get("cover_id")
|
||||||
|
|
||||||
|
cover_missing = (
|
||||||
|
not ebook.cover_image
|
||||||
|
or not ebook.cover_image.name
|
||||||
|
or not ebook.cover_image.storage.exists(ebook.cover_image.name)
|
||||||
|
)
|
||||||
|
if cover_id and cover_missing:
|
||||||
|
if ebook.cover_image:
|
||||||
|
ebook.cover_image.delete(save=False)
|
||||||
|
cover_bytes = download_cover(int(cover_id))
|
||||||
|
if cover_bytes:
|
||||||
|
filename = f"ol_cover_{ebook.pk}_{cover_id}.jpg"
|
||||||
|
ebook.cover_image.save(filename, ContentFile(cover_bytes), save=False)
|
||||||
|
update_fields.append("cover_image")
|
||||||
|
|
||||||
|
if result.match_score >= HIGH_CONFIDENCE:
|
||||||
|
ol_title = ol.get("title")
|
||||||
|
ol_authors = ol.get("authors") or []
|
||||||
|
if ol_title:
|
||||||
|
ebook.title = ol_title[:512]
|
||||||
|
update_fields.append("title")
|
||||||
|
if ol_authors:
|
||||||
|
ebook.author = ol_authors[0][:256]
|
||||||
|
update_fields.append("author")
|
||||||
|
|
||||||
|
ebook.save(update_fields=list(dict.fromkeys(update_fields)))
|
||||||
|
return ebook
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from config.settings import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SEARCH_URL = "https://openlibrary.org/search.json"
|
||||||
|
COVERS_URL = "https://covers.openlibrary.org/b/id/{cover_id}-{size}.jpg"
|
||||||
|
SEARCH_FIELDS = (
|
||||||
|
"key,title,author_name,cover_i,first_publish_year,subject,language,"
|
||||||
|
"edition_key,number_of_pages_median,publisher"
|
||||||
|
)
|
||||||
|
|
||||||
|
HIGH_CONFIDENCE = 0.8
|
||||||
|
LOW_CONFIDENCE = 0.6
|
||||||
|
|
||||||
|
# EPUB release noise often copied from filenames, e.g. "Title [6494] (r2.3)"
|
||||||
|
_TITLE_NOISE_PATTERNS = (
|
||||||
|
re.compile(r"\s*\[\d+\]"), # [6494]
|
||||||
|
re.compile(r"\s*\([rv][\d.]+\)", re.IGNORECASE), # (r2.3), (v1.0)
|
||||||
|
re.compile(r"\s*\(rev[\d.]*\)", re.IGNORECASE), # (rev2)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_search_title(title: str) -> str:
|
||||||
|
cleaned = title.strip()
|
||||||
|
for pattern in _TITLE_NOISE_PATTERNS:
|
||||||
|
cleaned = pattern.sub("", cleaned)
|
||||||
|
return re.sub(r"\s+", " ", cleaned).strip()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OpenLibraryHit:
|
||||||
|
work_key: str
|
||||||
|
title: str
|
||||||
|
authors: list[str]
|
||||||
|
cover_id: int | None
|
||||||
|
first_publish_year: int | None
|
||||||
|
subjects: list[str]
|
||||||
|
languages: list[str]
|
||||||
|
publishers: list[str]
|
||||||
|
edition_key: str | None
|
||||||
|
number_of_pages_median: int | None
|
||||||
|
match_language: str
|
||||||
|
score: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OpenLibraryMetadata:
|
||||||
|
match_language: str
|
||||||
|
match_score: float
|
||||||
|
match_status: str
|
||||||
|
openlibrary: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(text: str) -> str:
|
||||||
|
cleaned = re.sub(r"[^\w\s]", " ", _strip_accents(text).lower())
|
||||||
|
return re.sub(r"\s+", " ", cleaned).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_accents(text: str) -> str:
|
||||||
|
normalized = unicodedata.normalize("NFKD", text)
|
||||||
|
return "".join(ch for ch in normalized if not unicodedata.combining(ch))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_search_params(
|
||||||
|
title: str,
|
||||||
|
author: str,
|
||||||
|
*,
|
||||||
|
lang: str | None,
|
||||||
|
search_mode: str,
|
||||||
|
) -> dict[str, str | int]:
|
||||||
|
title = title.strip()
|
||||||
|
author = author.strip()
|
||||||
|
params: dict[str, str | int] = {"limit": 10, "fields": SEARCH_FIELDS}
|
||||||
|
|
||||||
|
if search_mode == "spanish_q":
|
||||||
|
q_parts = ["language:spa", title]
|
||||||
|
if author:
|
||||||
|
q_parts.append(author)
|
||||||
|
params["q"] = " ".join(q_parts)
|
||||||
|
elif search_mode == "q":
|
||||||
|
params["q"] = " ".join(part for part in (title, author) if part)
|
||||||
|
elif search_mode == "q_unaccent":
|
||||||
|
params["q"] = " ".join(
|
||||||
|
part for part in (_strip_accents(title), _strip_accents(author) if author else "") if part
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
params["title"] = title
|
||||||
|
if author:
|
||||||
|
params["author"] = author
|
||||||
|
|
||||||
|
if lang:
|
||||||
|
params["lang"] = lang
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
def _title_similarity(a: str, b: str) -> float:
|
||||||
|
na, nb = _normalize(a), _normalize(b)
|
||||||
|
if not na or not nb:
|
||||||
|
return 0.0
|
||||||
|
if na in nb or nb in na:
|
||||||
|
return 1.0
|
||||||
|
return SequenceMatcher(None, na, nb).ratio()
|
||||||
|
|
||||||
|
|
||||||
|
def _author_overlap(user_author: str, ol_authors: list[str]) -> float:
|
||||||
|
if not user_author.strip():
|
||||||
|
return 0.5 if ol_authors else 0.0
|
||||||
|
user_tokens = set(_normalize(user_author).split())
|
||||||
|
if not user_tokens:
|
||||||
|
return 0.0
|
||||||
|
best = 0.0
|
||||||
|
for name in ol_authors:
|
||||||
|
name_tokens = set(_normalize(name).split())
|
||||||
|
if not name_tokens:
|
||||||
|
continue
|
||||||
|
overlap = len(user_tokens & name_tokens) / len(user_tokens)
|
||||||
|
best = max(best, overlap)
|
||||||
|
if user_tokens <= name_tokens or name_tokens <= user_tokens:
|
||||||
|
best = max(best, 0.95)
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _score_hit(title: str, author: str, doc: dict[str, Any], *, lang: str) -> float:
|
||||||
|
ol_title = doc.get("title") or ""
|
||||||
|
ol_authors = doc.get("author_name") or []
|
||||||
|
title_score = _title_similarity(title, ol_title)
|
||||||
|
author_score = _author_overlap(author, ol_authors)
|
||||||
|
combined = (title_score * 0.6) + (author_score * 0.4)
|
||||||
|
if doc.get("cover_i"):
|
||||||
|
combined += 0.05
|
||||||
|
return min(combined, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_hit(doc: dict[str, Any], *, lang: str, score: float) -> OpenLibraryHit:
|
||||||
|
edition_keys = doc.get("edition_key") or []
|
||||||
|
edition_key = edition_keys[0] if edition_keys else None
|
||||||
|
cover_id = doc.get("cover_i")
|
||||||
|
return OpenLibraryHit(
|
||||||
|
work_key=doc.get("key") or "",
|
||||||
|
title=doc.get("title") or "",
|
||||||
|
authors=list(doc.get("author_name") or []),
|
||||||
|
cover_id=int(cover_id) if cover_id else None,
|
||||||
|
first_publish_year=doc.get("first_publish_year"),
|
||||||
|
subjects=list(doc.get("subject") or [])[:10],
|
||||||
|
languages=list(doc.get("language") or []),
|
||||||
|
publishers=list(doc.get("publisher") or [])[:5],
|
||||||
|
edition_key=edition_key,
|
||||||
|
number_of_pages_median=doc.get("number_of_pages_median"),
|
||||||
|
match_language=lang,
|
||||||
|
score=score,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_cover_url(cover_id: int, size: str = "L") -> str:
|
||||||
|
return COVERS_URL.format(cover_id=cover_id, size=size)
|
||||||
|
|
||||||
|
|
||||||
|
def _client() -> httpx.Client:
|
||||||
|
read_timeout = settings.OPENLIBRARY_TIMEOUT_SECONDS
|
||||||
|
connect_timeout = settings.OPENLIBRARY_CONNECT_TIMEOUT_SECONDS
|
||||||
|
return httpx.Client(
|
||||||
|
timeout=httpx.Timeout(
|
||||||
|
connect=connect_timeout,
|
||||||
|
read=read_timeout,
|
||||||
|
write=read_timeout,
|
||||||
|
pool=connect_timeout,
|
||||||
|
),
|
||||||
|
headers={"User-Agent": settings.OPENLIBRARY_USER_AGENT},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def search_works(
|
||||||
|
title: str,
|
||||||
|
author: str,
|
||||||
|
*,
|
||||||
|
lang: str | None = None,
|
||||||
|
search_mode: str = "title",
|
||||||
|
client: httpx.Client | None = None,
|
||||||
|
) -> list[OpenLibraryHit]:
|
||||||
|
if not title.strip():
|
||||||
|
return []
|
||||||
|
|
||||||
|
params = _build_search_params(title, author, lang=lang, search_mode=search_mode)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if client is not None:
|
||||||
|
response = client.get(SEARCH_URL, params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
docs = response.json().get("docs") or []
|
||||||
|
else:
|
||||||
|
with _client() as owned_client:
|
||||||
|
response = owned_client.get(SEARCH_URL, params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
docs = response.json().get("docs") or []
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
logger.warning("Open Library search failed: %s", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
hits: list[OpenLibraryHit] = []
|
||||||
|
for doc in docs:
|
||||||
|
score = _score_hit(title, author, doc, lang=lang or "")
|
||||||
|
if score < LOW_CONFIDENCE:
|
||||||
|
continue
|
||||||
|
hits.append(_parse_hit(doc, lang=lang or "", score=score))
|
||||||
|
hits.sort(key=lambda h: (h.score, h.cover_id is not None), reverse=True)
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def pick_best_match(title: str, author: str, hits: list[OpenLibraryHit]) -> OpenLibraryHit | None:
|
||||||
|
return hits[0] if hits else None
|
||||||
|
|
||||||
|
|
||||||
|
def download_cover(cover_id: int) -> bytes | None:
|
||||||
|
url = build_cover_url(cover_id, size="L")
|
||||||
|
try:
|
||||||
|
with _client() as client:
|
||||||
|
response = client.get(url)
|
||||||
|
if response.status_code == 404:
|
||||||
|
return None
|
||||||
|
response.raise_for_status()
|
||||||
|
content_type = response.headers.get("content-type", "")
|
||||||
|
if not content_type.startswith("image/"):
|
||||||
|
return None
|
||||||
|
return response.content
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
logger.warning("Open Library cover download failed for %s: %s", cover_id, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _hit_to_openlibrary_dict(hit: OpenLibraryHit) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"work_key": hit.work_key,
|
||||||
|
"edition_key": hit.edition_key,
|
||||||
|
"title": hit.title,
|
||||||
|
"authors": hit.authors,
|
||||||
|
"cover_id": hit.cover_id,
|
||||||
|
"cover_url": build_cover_url(hit.cover_id) if hit.cover_id else None,
|
||||||
|
"first_publish_year": hit.first_publish_year,
|
||||||
|
"subjects": hit.subjects,
|
||||||
|
"languages": hit.languages,
|
||||||
|
"publishers": hit.publishers,
|
||||||
|
"number_of_pages_median": hit.number_of_pages_median,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_metadata(title: str, author: str) -> OpenLibraryMetadata | None:
|
||||||
|
if not settings.OPENLIBRARY_ENABLED:
|
||||||
|
return None
|
||||||
|
|
||||||
|
preferred = settings.OPENLIBRARY_PREFERRED_LANG
|
||||||
|
fallback = settings.OPENLIBRARY_FALLBACK_LANG
|
||||||
|
search_title = _sanitize_search_title(title)
|
||||||
|
|
||||||
|
if not search_title:
|
||||||
|
return OpenLibraryMetadata(
|
||||||
|
match_language=preferred,
|
||||||
|
match_score=0.0,
|
||||||
|
match_status="not_found",
|
||||||
|
openlibrary={},
|
||||||
|
)
|
||||||
|
|
||||||
|
search_plan: list[tuple[str, str | None]] = [
|
||||||
|
("spanish_q", preferred),
|
||||||
|
("title", preferred),
|
||||||
|
("q", None),
|
||||||
|
("q_unaccent", None),
|
||||||
|
]
|
||||||
|
if fallback != preferred:
|
||||||
|
search_plan.extend([("title", fallback), ("q", fallback)])
|
||||||
|
|
||||||
|
hits: list[OpenLibraryHit] = []
|
||||||
|
with _client() as client:
|
||||||
|
for search_mode, lang in search_plan:
|
||||||
|
hits = search_works(search_title, author, lang=lang, search_mode=search_mode, client=client)
|
||||||
|
if hits:
|
||||||
|
break
|
||||||
|
|
||||||
|
hit = pick_best_match(title, author, hits)
|
||||||
|
if not hit:
|
||||||
|
return OpenLibraryMetadata(
|
||||||
|
match_language=preferred,
|
||||||
|
match_score=0.0,
|
||||||
|
match_status="not_found",
|
||||||
|
openlibrary={},
|
||||||
|
)
|
||||||
|
|
||||||
|
status = "matched" if hit.score >= HIGH_CONFIDENCE else "partial"
|
||||||
|
return OpenLibraryMetadata(
|
||||||
|
match_language=hit.match_language,
|
||||||
|
match_score=hit.score,
|
||||||
|
match_status=status,
|
||||||
|
openlibrary=_hit_to_openlibrary_dict(hit),
|
||||||
|
)
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"""Extract TOC, metadata, and page count from uploaded EPUB/PDF files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from apps.books.models import BookChapter, EBook
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def process_ebook(file_path: str, original_filename: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Parse an e-book file and return format, metadata, TOC, and page count."""
|
||||||
|
ext = Path(original_filename or file_path).suffix.lower()
|
||||||
|
if ext == ".epub" or file_path.lower().endswith(".epub"):
|
||||||
|
return _process_epub(file_path)
|
||||||
|
if ext == ".pdf" or file_path.lower().endswith(".pdf"):
|
||||||
|
return _process_pdf(file_path)
|
||||||
|
return {"format": ext.lstrip(".") or "unknown", "page_count": 0, "metadata": {}, "toc": []}
|
||||||
|
|
||||||
|
|
||||||
|
def store_chapters(ebook: EBook, toc: list[dict[str, Any]], parent_index: int = 0) -> None:
|
||||||
|
"""Recursively store TOC entries as BookChapter records."""
|
||||||
|
for idx, entry in enumerate(toc):
|
||||||
|
BookChapter.objects.create(
|
||||||
|
ebook=ebook,
|
||||||
|
title=entry.get("title", "Untitled"),
|
||||||
|
index=parent_index + idx,
|
||||||
|
href=entry.get("href", ""),
|
||||||
|
children=entry.get("children", []),
|
||||||
|
)
|
||||||
|
children = entry.get("children", [])
|
||||||
|
if children:
|
||||||
|
store_chapters(ebook, children, parent_index + idx + 1)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_processing_to_ebook(ebook: EBook) -> dict[str, Any]:
|
||||||
|
"""Run processing on an EBook instance and persist chapters + metadata."""
|
||||||
|
if not ebook.file:
|
||||||
|
raise ValueError("No file found for this e-book.")
|
||||||
|
|
||||||
|
file_path = ebook.file.path
|
||||||
|
result = process_ebook(file_path, original_filename=ebook.filename())
|
||||||
|
|
||||||
|
ebook.format = result.get("format", ebook.format)
|
||||||
|
ebook.page_count = result.get("page_count", 0)
|
||||||
|
file_metadata = result.get("metadata") or {}
|
||||||
|
if file_metadata:
|
||||||
|
merged = dict(ebook.metadata_json or {})
|
||||||
|
merged["file"] = file_metadata
|
||||||
|
ebook.metadata_json = merged
|
||||||
|
ebook.save(update_fields=["format", "page_count", "metadata_json", "updated_at"])
|
||||||
|
|
||||||
|
raw_toc: list[dict[str, Any]] = result.get("toc", [])
|
||||||
|
BookChapter.objects.filter(ebook=ebook).delete()
|
||||||
|
store_chapters(ebook, raw_toc)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"format": ebook.format,
|
||||||
|
"page_count": ebook.page_count,
|
||||||
|
"metadata": ebook.metadata_json,
|
||||||
|
"toc_count": len(raw_toc),
|
||||||
|
"status": "processed",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _process_epub(file_path: str) -> dict[str, Any]:
|
||||||
|
from ebooklib import epub, ITEM_DOCUMENT
|
||||||
|
|
||||||
|
book = epub.read_epub(file_path)
|
||||||
|
metadata = _extract_epub_metadata(book)
|
||||||
|
toc = _extract_epub_toc(book)
|
||||||
|
|
||||||
|
if not toc:
|
||||||
|
toc = _fallback_toc_from_spine(book, ITEM_DOCUMENT)
|
||||||
|
|
||||||
|
flat_count = _count_toc_entries(toc)
|
||||||
|
page_count = flat_count or len(book.spine)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"format": "epub",
|
||||||
|
"page_count": page_count,
|
||||||
|
"metadata": metadata,
|
||||||
|
"toc": toc,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _process_pdf(file_path: str) -> dict[str, Any]:
|
||||||
|
from pypdf import PdfReader
|
||||||
|
|
||||||
|
reader = PdfReader(file_path)
|
||||||
|
page_count = len(reader.pages)
|
||||||
|
metadata = _extract_pdf_metadata(reader)
|
||||||
|
toc = _extract_pdf_outline(reader)
|
||||||
|
if not toc and page_count > 0:
|
||||||
|
toc = [
|
||||||
|
{"title": f"Page {i}", "href": f"pdf:page:{i}", "children": []}
|
||||||
|
for i in range(1, page_count + 1)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"format": "pdf",
|
||||||
|
"page_count": page_count,
|
||||||
|
"metadata": metadata,
|
||||||
|
"toc": toc,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_pdf_metadata(reader) -> dict[str, Any]:
|
||||||
|
metadata: dict[str, Any] = {}
|
||||||
|
info = reader.metadata
|
||||||
|
if not info:
|
||||||
|
return metadata
|
||||||
|
title = getattr(info, "title", None)
|
||||||
|
author = getattr(info, "author", None)
|
||||||
|
if title:
|
||||||
|
metadata["title"] = str(title)
|
||||||
|
if author:
|
||||||
|
metadata["author"] = str(author)
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_pdf_outline(reader) -> list[dict[str, Any]]:
|
||||||
|
outline = getattr(reader, "outline", None)
|
||||||
|
if not outline:
|
||||||
|
return []
|
||||||
|
return _walk_pdf_outline(reader, outline)
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_pdf_outline(reader, outline: list[Any]) -> list[dict[str, Any]]:
|
||||||
|
entries: list[dict[str, Any]] = []
|
||||||
|
i = 0
|
||||||
|
while i < len(outline):
|
||||||
|
item = outline[i]
|
||||||
|
if isinstance(item, list):
|
||||||
|
if entries:
|
||||||
|
entries[-1]["children"] = _walk_pdf_outline(reader, item)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
title = getattr(item, "title", None) or "Section"
|
||||||
|
page_num = 1
|
||||||
|
try:
|
||||||
|
page_num = reader.get_destination_page_number(item) + 1
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not resolve PDF outline destination", exc_info=True)
|
||||||
|
entries.append({
|
||||||
|
"title": str(title),
|
||||||
|
"href": f"pdf:page:{page_num}",
|
||||||
|
"children": [],
|
||||||
|
})
|
||||||
|
i += 1
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_epub_metadata(book) -> dict[str, Any]:
|
||||||
|
metadata: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def first(namespace: str, name: str) -> str:
|
||||||
|
values = book.get_metadata(namespace, name)
|
||||||
|
if values:
|
||||||
|
return str(values[0][0])
|
||||||
|
return ""
|
||||||
|
|
||||||
|
title = first("DC", "title")
|
||||||
|
if title:
|
||||||
|
metadata["title"] = title
|
||||||
|
creator = first("DC", "creator")
|
||||||
|
if creator:
|
||||||
|
metadata["author"] = creator
|
||||||
|
language = first("DC", "language")
|
||||||
|
if language:
|
||||||
|
metadata["language"] = language
|
||||||
|
identifier = first("DC", "identifier")
|
||||||
|
if identifier:
|
||||||
|
metadata["identifier"] = identifier
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_toc_item(item) -> dict[str, Any]:
|
||||||
|
from ebooklib import epub
|
||||||
|
|
||||||
|
if isinstance(item, epub.Link):
|
||||||
|
return {
|
||||||
|
"title": item.title or "Untitled",
|
||||||
|
"href": item.href or "",
|
||||||
|
"children": [],
|
||||||
|
}
|
||||||
|
if isinstance(item, tuple):
|
||||||
|
section, children = item
|
||||||
|
entry = {
|
||||||
|
"title": getattr(section, "title", None) or "Untitled",
|
||||||
|
"href": getattr(section, "href", None) or "",
|
||||||
|
"children": [],
|
||||||
|
}
|
||||||
|
for child in children:
|
||||||
|
entry["children"].append(_parse_toc_item(child))
|
||||||
|
return entry
|
||||||
|
if hasattr(item, "title"):
|
||||||
|
return {
|
||||||
|
"title": item.title or "Untitled",
|
||||||
|
"href": getattr(item, "href", "") or "",
|
||||||
|
"children": [],
|
||||||
|
}
|
||||||
|
return {"title": "Untitled", "href": "", "children": []}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_epub_toc(book) -> list[dict[str, Any]]:
|
||||||
|
return [_parse_toc_item(item) for item in book.toc]
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_toc_from_spine(book, item_document_type) -> list[dict[str, Any]]:
|
||||||
|
toc: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
chapter_num = 0
|
||||||
|
|
||||||
|
for spine_entry in book.spine:
|
||||||
|
item_id = spine_entry[0] if isinstance(spine_entry, tuple) else spine_entry
|
||||||
|
item = book.get_item_with_id(item_id)
|
||||||
|
if not item or item.get_type() != item_document_type:
|
||||||
|
continue
|
||||||
|
href = item.get_name() or ""
|
||||||
|
if not href or href in seen:
|
||||||
|
continue
|
||||||
|
seen.add(href)
|
||||||
|
chapter_num += 1
|
||||||
|
title = os.path.splitext(os.path.basename(href))[0] or f"Chapter {chapter_num}"
|
||||||
|
toc.append({"title": title.replace("_", " ").replace("-", " "), "href": href, "children": []})
|
||||||
|
|
||||||
|
return toc
|
||||||
|
|
||||||
|
|
||||||
|
def _count_toc_entries(toc: list[dict[str, Any]]) -> int:
|
||||||
|
count = 0
|
||||||
|
for entry in toc:
|
||||||
|
count += 1
|
||||||
|
count += _count_toc_entries(entry.get("children", []))
|
||||||
|
return count
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
"""Tests for the Book search & discovery endpoints."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from apps.books.models import Book, ReadingStatus
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def api_client():
|
||||||
|
return APIClient()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user(django_user_model):
|
||||||
|
return django_user_model.objects.create_user(
|
||||||
|
email="reader@example.com",
|
||||||
|
password="testpass123",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_client(api_client, user):
|
||||||
|
api_client.force_authenticate(user=user)
|
||||||
|
return api_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def books():
|
||||||
|
books_data = [
|
||||||
|
Book.objects.create(
|
||||||
|
title="Dune",
|
||||||
|
author="Frank Herbert",
|
||||||
|
genre="Science Fiction",
|
||||||
|
reading_status=ReadingStatus.FINISHED,
|
||||||
|
total_pages=688,
|
||||||
|
description="A desert planet saga.",
|
||||||
|
),
|
||||||
|
Book.objects.create(
|
||||||
|
title="Neuromancer",
|
||||||
|
author="William Gibson",
|
||||||
|
genre="Science Fiction",
|
||||||
|
reading_status=ReadingStatus.READING,
|
||||||
|
total_pages=271,
|
||||||
|
description="Cyberpunk classic.",
|
||||||
|
),
|
||||||
|
Book.objects.create(
|
||||||
|
title="The Hobbit",
|
||||||
|
author="J.R.R. Tolkien",
|
||||||
|
genre="Fantasy",
|
||||||
|
reading_status=ReadingStatus.WANT_TO_READ,
|
||||||
|
total_pages=310,
|
||||||
|
description="A hobbit's adventure.",
|
||||||
|
),
|
||||||
|
Book.objects.create(
|
||||||
|
title="1984",
|
||||||
|
author="George Orwell",
|
||||||
|
genre="Dystopian",
|
||||||
|
reading_status=ReadingStatus.FINISHED,
|
||||||
|
total_pages=328,
|
||||||
|
description="Big Brother is watching.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return books_data
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Search tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestBookSearch:
|
||||||
|
"""Verify the search endpoint returns correct results."""
|
||||||
|
|
||||||
|
def test_search_by_title(self, auth_client, books):
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"q": "Dune"})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
titles = [b["title"] for b in response.data["results"]]
|
||||||
|
assert "Dune" in titles
|
||||||
|
assert "Neuromancer" not in titles
|
||||||
|
|
||||||
|
def test_search_by_author(self, auth_client, books):
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"q": "Tolkien"})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
titles = [b["title"] for b in response.data["results"]]
|
||||||
|
assert "The Hobbit" in titles
|
||||||
|
|
||||||
|
def test_search_by_genre(self, auth_client, books):
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"q": "Fantasy"})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
titles = [b["title"] for b in response.data["results"]]
|
||||||
|
assert "The Hobbit" in titles
|
||||||
|
|
||||||
|
def test_search_case_insensitive(self, auth_client, books):
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"q": "dune"})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert any(b["title"] == "Dune" for b in response.data["results"])
|
||||||
|
|
||||||
|
def test_search_partial_match(self, auth_client, books):
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"q": "Neu"})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
titles = [b["title"] for b in response.data["results"]]
|
||||||
|
assert "Neuromancer" in titles
|
||||||
|
|
||||||
|
def test_search_empty_query_returns_all(self, auth_client, books):
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"q": ""})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert len(response.data["results"]) == 4
|
||||||
|
|
||||||
|
def test_search_no_results(self, auth_client, books):
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"q": "zzzznotfound"})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert len(response.data["results"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Filter tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestBookFilters:
|
||||||
|
"""Verify filters for genre, author, and reading_status."""
|
||||||
|
|
||||||
|
def test_filter_by_genre(self, auth_client, books):
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"genre": "Science Fiction"})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
titles = [b["title"] for b in response.data["results"]]
|
||||||
|
assert "Dune" in titles
|
||||||
|
assert "Neuromancer" in titles
|
||||||
|
assert "The Hobbit" not in titles
|
||||||
|
|
||||||
|
def test_filter_by_author(self, auth_client, books):
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"author": "George Orwell"})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
titles = [b["title"] for b in response.data["results"]]
|
||||||
|
assert "1984" in titles
|
||||||
|
assert "Dune" not in titles
|
||||||
|
|
||||||
|
def test_filter_by_reading_status(self, auth_client, books):
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"reading_status": ReadingStatus.FINISHED})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
titles = [b["title"] for b in response.data["results"]]
|
||||||
|
assert "Dune" in titles
|
||||||
|
assert "1984" in titles
|
||||||
|
assert "Neuromancer" not in titles
|
||||||
|
assert "The Hobbit" not in titles
|
||||||
|
|
||||||
|
def test_filter_combined_with_search(self, auth_client, books):
|
||||||
|
"""Search + filter should intersect results."""
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"q": "Dune", "reading_status": ReadingStatus.FINISHED})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
titles = [b["title"] for b in response.data["results"]]
|
||||||
|
assert "Dune" in titles
|
||||||
|
# 1984 matches reading_status but not search
|
||||||
|
assert "1984" not in titles
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Discovery endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestBookDiscovery:
|
||||||
|
"""Verify genre and author discovery endpoints."""
|
||||||
|
|
||||||
|
def test_genres_endpoint(self, auth_client, books):
|
||||||
|
url = reverse("book-genres")
|
||||||
|
response = auth_client.get(url)
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert isinstance(response.data, list)
|
||||||
|
assert "Science Fiction" in response.data
|
||||||
|
assert "Fantasy" in response.data
|
||||||
|
assert "Dystopian" in response.data
|
||||||
|
# No duplicate genres
|
||||||
|
assert response.data.count("Science Fiction") == 1
|
||||||
|
|
||||||
|
def test_authors_endpoint(self, auth_client, books):
|
||||||
|
url = reverse("book-authors")
|
||||||
|
response = auth_client.get(url)
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert isinstance(response.data, list)
|
||||||
|
assert "Frank Herbert" in response.data
|
||||||
|
assert "J.R.R. Tolkien" in response.data
|
||||||
|
|
||||||
|
def test_genres_requires_auth(self, api_client, books):
|
||||||
|
url = reverse("book-genres")
|
||||||
|
response = api_client.get(url)
|
||||||
|
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||||
|
|
||||||
|
def test_authors_requires_auth(self, api_client, books):
|
||||||
|
url = reverse("book-authors")
|
||||||
|
response = api_client.get(url)
|
||||||
|
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Detail view
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestBookDetail:
|
||||||
|
"""Verify the book detail endpoint."""
|
||||||
|
|
||||||
|
def test_retrieve_book(self, auth_client, books):
|
||||||
|
book = books[0]
|
||||||
|
url = reverse("book-detail", kwargs={"pk": book.pk})
|
||||||
|
response = auth_client.get(url)
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data["title"] == "Dune"
|
||||||
|
assert response.data["author"] == "Frank Herbert"
|
||||||
|
assert response.data["description"] == "A desert planet saga."
|
||||||
|
assert response.data["total_pages"] == 688
|
||||||
|
|
||||||
|
def test_retrieve_nonexistent_returns_404(self, auth_client, books):
|
||||||
|
url = reverse("book-detail", kwargs={"pk": 99999})
|
||||||
|
response = auth_client.get(url)
|
||||||
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
|
|
||||||
|
def test_pagination(self, auth_client, books):
|
||||||
|
"""List with small page size should paginate."""
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(f"{url}?page_size=2")
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert "count" in response.data
|
||||||
|
assert "results" in response.data
|
||||||
|
assert response.data["count"] == 4
|
||||||
|
|
||||||
|
def test_ordering(self, auth_client, books):
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"ordering": "title"})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
titles = [b["title"] for b in response.data["results"]]
|
||||||
|
assert titles == sorted(titles)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from django.urls import include, path
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from apps.books.views import BookViewSet, EBookViewSet, book_reading_settings_view
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"ebooks", EBookViewSet, basename="ebook")
|
||||||
|
router.register(r"", BookViewSet, basename="book")
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("settings/", book_reading_settings_view, name="book-settings"),
|
||||||
|
path("", include(router.urls)),
|
||||||
|
]
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.db.models import QuerySet, Q
|
||||||
|
from django.http import FileResponse
|
||||||
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
from rest_framework import parsers, permissions, status, viewsets
|
||||||
|
from rest_framework.decorators import action, api_view, permission_classes
|
||||||
|
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||||
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from apps.books.models import Book, BookChapter, DownloadRecord, EBook, ReadingProgress
|
||||||
|
from apps.books.serializers import (
|
||||||
|
BookChapterSerializer, BookDetailSerializer, BookListSerializer, BookReadingSettingsSerializer,
|
||||||
|
BookSerializer, DownloadRecordSerializer, EBookContentSerializer, EBookDetailSerializer,
|
||||||
|
EBookListSerializer, EBookTocSerializer, EBookUploadSerializer,
|
||||||
|
ReadingProgressSerializer, StorageSummarySerializer,
|
||||||
|
)
|
||||||
|
from apps.reader.models import ReadingSettings
|
||||||
|
from apps.books.services.ebook_metadata import subjects_from_ebook
|
||||||
|
from apps.books.services.metadata import enrich_ebook_metadata
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BookViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Book.objects.all()
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||||
|
filterset_fields = ["author", "genre", "reading_status"]
|
||||||
|
search_fields = ["title", "author", "genre"]
|
||||||
|
ordering_fields = ["title", "author", "genre", "created_at"]
|
||||||
|
ordering = ["title"]
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == "retrieve":
|
||||||
|
return BookDetailSerializer
|
||||||
|
if self.action == "list":
|
||||||
|
return BookListSerializer
|
||||||
|
return BookSerializer
|
||||||
|
|
||||||
|
def get_queryset(self) -> QuerySet[Book]:
|
||||||
|
qs = super().get_queryset()
|
||||||
|
query = self.request.query_params.get("q", "").strip()
|
||||||
|
if query:
|
||||||
|
qs = qs.filter(Q(title__icontains=query) | Q(author__icontains=query) | Q(genre__icontains=query))
|
||||||
|
return qs
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"], permission_classes=[AllowAny])
|
||||||
|
def genres(self, request: Request) -> Response:
|
||||||
|
genre_list = Book.objects.values_list("genre", flat=True).distinct().order_by("genre")
|
||||||
|
return Response([g for g in genre_list if g])
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"], permission_classes=[AllowAny])
|
||||||
|
def authors(self, request: Request) -> Response:
|
||||||
|
author_list = Book.objects.values_list("author", flat=True).distinct().order_by("author")
|
||||||
|
return Response([a for a in author_list if a])
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def storage(self, request: Request) -> Response:
|
||||||
|
"""Return storage usage summary for the current user."""
|
||||||
|
download_records = DownloadRecord.objects.filter(user=request.user).select_related("ebook")
|
||||||
|
total_size = sum(r.file_size for r in download_records)
|
||||||
|
ebook_list = [
|
||||||
|
{"id": r.ebook.id, "title": r.ebook.title, "file_size": r.file_size}
|
||||||
|
for r in download_records
|
||||||
|
]
|
||||||
|
serializer = StorageSummarySerializer(data={
|
||||||
|
"total_downloads": download_records.count(),
|
||||||
|
"total_size_bytes": total_size,
|
||||||
|
"ebooks": ebook_list,
|
||||||
|
})
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class IsEBookOwner(permissions.BasePermission):
|
||||||
|
def has_object_permission(self, request: Request, view: object, obj: EBook) -> bool:
|
||||||
|
return obj.user == request.user
|
||||||
|
|
||||||
|
|
||||||
|
class EBookViewSet(viewsets.ModelViewSet):
|
||||||
|
parser_classes = [parsers.MultiPartParser, parsers.FormParser, parsers.JSONParser]
|
||||||
|
permission_classes = [IsAuthenticated, IsEBookOwner]
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == "create":
|
||||||
|
return EBookUploadSerializer
|
||||||
|
if self.action in ("list",):
|
||||||
|
return EBookListSerializer
|
||||||
|
if self.action in ("toc",):
|
||||||
|
return BookChapterSerializer
|
||||||
|
return EBookDetailSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = EBook.objects.filter(user=self.request.user).select_related("reading_progress", "user")
|
||||||
|
query = self.request.query_params.get("q", "").strip()
|
||||||
|
if query and self.action == "list":
|
||||||
|
qs = qs.filter(
|
||||||
|
Q(title__icontains=query) | Q(author__icontains=query),
|
||||||
|
)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def genres(self, request: Request) -> Response:
|
||||||
|
genre_set: set[str] = set()
|
||||||
|
for ebook in self.get_queryset():
|
||||||
|
genre_set.update(subjects_from_ebook(ebook))
|
||||||
|
return Response(sorted(genre_set))
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def authors(self, request: Request) -> Response:
|
||||||
|
author_list = (
|
||||||
|
self.get_queryset()
|
||||||
|
.values_list("author", flat=True)
|
||||||
|
.distinct()
|
||||||
|
.order_by("author")
|
||||||
|
)
|
||||||
|
return Response([a for a in author_list if a])
|
||||||
|
|
||||||
|
@action(detail=True, methods=["get", "patch"])
|
||||||
|
def progress(self, request: Request, pk: int | None = None) -> Response:
|
||||||
|
ebook = self.get_object()
|
||||||
|
progress_obj, _created = ReadingProgress.objects.get_or_create(user=request.user, ebook=ebook)
|
||||||
|
if request.method == "GET":
|
||||||
|
serializer = ReadingProgressSerializer(progress_obj)
|
||||||
|
return Response(serializer.data)
|
||||||
|
serializer = ReadingProgressSerializer(progress_obj, data=request.data, partial=True)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"], url_path="enrich-metadata")
|
||||||
|
def enrich_metadata(self, request: Request, pk: int | None = None) -> Response:
|
||||||
|
"""Re-fetch Open Library metadata and cover for this ebook."""
|
||||||
|
ebook = self.get_object()
|
||||||
|
try:
|
||||||
|
enrich_ebook_metadata(ebook)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Manual metadata enrichment failed for ebook %s", ebook.id)
|
||||||
|
return Response(
|
||||||
|
{"error": "Metadata enrichment failed."},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
)
|
||||||
|
ebook.refresh_from_db()
|
||||||
|
serializer = EBookDetailSerializer(ebook, context={"request": request})
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"])
|
||||||
|
def process(self, request: Request, pk: int | None = None) -> Response:
|
||||||
|
"""Trigger e-book processing: metadata extraction, TOC building, page counting."""
|
||||||
|
ebook = self.get_object()
|
||||||
|
if not ebook.file:
|
||||||
|
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from apps.books.services.process_ebook import apply_processing_to_ebook
|
||||||
|
|
||||||
|
result = apply_processing_to_ebook(ebook)
|
||||||
|
return Response(result)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to process ebook %s", ebook.id)
|
||||||
|
return Response({"error": f"Processing failed: {exc}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["get"])
|
||||||
|
def file(self, request: Request, pk: int | None = None) -> FileResponse | Response:
|
||||||
|
"""Stream the ebook file for authenticated in-browser reading."""
|
||||||
|
ebook = self.get_object()
|
||||||
|
content_types = {
|
||||||
|
"epub": "application/epub+zip",
|
||||||
|
"pdf": "application/pdf",
|
||||||
|
}
|
||||||
|
content_type = content_types.get(ebook.format)
|
||||||
|
if not content_type:
|
||||||
|
return Response(
|
||||||
|
{"error": "Unsupported format for in-browser reading."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
if not ebook.file:
|
||||||
|
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
return FileResponse(
|
||||||
|
ebook.file.open("rb"),
|
||||||
|
content_type=content_type,
|
||||||
|
filename=ebook.filename(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["get"])
|
||||||
|
def toc(self, request: Request, pk: int | None = None) -> Response:
|
||||||
|
"""Return hierarchical table of contents."""
|
||||||
|
ebook = self.get_object()
|
||||||
|
chapters = BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook")
|
||||||
|
serializer = BookChapterSerializer(chapters, many=True)
|
||||||
|
return Response({
|
||||||
|
"chapters": serializer.data,
|
||||||
|
"format": ebook.format,
|
||||||
|
"page_count": ebook.page_count,
|
||||||
|
})
|
||||||
|
|
||||||
|
@action(detail=True, methods=["get"])
|
||||||
|
def content(self, request: Request, pk: int | None = None) -> Response:
|
||||||
|
"""Return paginated content for a given page number.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
page (int): page/chapter index to fetch (1-indexed, default: 1)
|
||||||
|
"""
|
||||||
|
ebook = self.get_object()
|
||||||
|
page = max(1, int(request.query_params.get("page", 1)))
|
||||||
|
|
||||||
|
chapters = list(BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook"))
|
||||||
|
total_pages = len(chapters) or ebook.page_count or 1
|
||||||
|
|
||||||
|
chapter: BookChapter | None = None
|
||||||
|
chapter_title = ""
|
||||||
|
content_html = ""
|
||||||
|
|
||||||
|
if chapters and 0 <= page - 1 < len(chapters):
|
||||||
|
ch = chapters[page - 1]
|
||||||
|
chapter_title = ch.title
|
||||||
|
content_html = _fetch_chapter_content(ebook, ch)
|
||||||
|
|
||||||
|
serializer = EBookContentSerializer(data={
|
||||||
|
"page": page,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
"content": content_html,
|
||||||
|
"chapter_title": chapter_title,
|
||||||
|
"format": ebook.format,
|
||||||
|
})
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"])
|
||||||
|
def download(self, request: Request, pk: int | None = None) -> Response:
|
||||||
|
"""Track download of an e-book. Creates a DownloadRecord and returns file info."""
|
||||||
|
ebook = self.get_object()
|
||||||
|
if not ebook.file:
|
||||||
|
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
download, created = DownloadRecord.objects.get_or_create(
|
||||||
|
user=request.user,
|
||||||
|
ebook=ebook,
|
||||||
|
defaults={"file_size": ebook.file.size if ebook.file else 0},
|
||||||
|
)
|
||||||
|
if not created:
|
||||||
|
download.file_size = ebook.file.size if ebook.file else 0
|
||||||
|
download.save(update_fields=["file_size"])
|
||||||
|
|
||||||
|
serializer = DownloadRecordSerializer(download, context={"request": request})
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def downloads(self, request: Request) -> Response:
|
||||||
|
"""List all e-books the current user has downloaded."""
|
||||||
|
records = DownloadRecord.objects.filter(user=request.user).select_related(
|
||||||
|
"ebook", "ebook__reading_progress"
|
||||||
|
).prefetch_related("ebook__chapters")
|
||||||
|
page = self.paginate_queryset(records)
|
||||||
|
if page is not None:
|
||||||
|
serializer = DownloadRecordSerializer(page, many=True, context={"request": request})
|
||||||
|
return self.get_paginated_response(serializer.data)
|
||||||
|
serializer = DownloadRecordSerializer(records, many=True, context={"request": request})
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["delete"], url_path="downloads/(?P<download_pk>[^/.]+)")
|
||||||
|
def delete_download(self, request: Request, download_pk: str | None = None) -> Response:
|
||||||
|
"""Delete a download record."""
|
||||||
|
try:
|
||||||
|
download = DownloadRecord.objects.get(pk=download_pk, user=request.user)
|
||||||
|
except DownloadRecord.DoesNotExist:
|
||||||
|
return Response({"error": "Download record not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
download.delete()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_chapter_content(ebook: EBook, chapter: BookChapter) -> str:
|
||||||
|
"""Fetch HTML content for a chapter from the e-book file."""
|
||||||
|
if ebook.format == "epub":
|
||||||
|
return _fetch_epub_chapter_content(ebook.file.path, chapter)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_epub_chapter_content(file_path: str, chapter: BookChapter) -> str:
|
||||||
|
"""Extract HTML content of a specific EPUB chapter by href."""
|
||||||
|
try:
|
||||||
|
from ebooklib import epub
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
except ImportError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
book = epub.read_epub(file_path)
|
||||||
|
href = chapter.href or ""
|
||||||
|
# Find the item by href
|
||||||
|
for item in book.get_items():
|
||||||
|
item_name = item.get_name() or ""
|
||||||
|
if href and (item_name.endswith(href) or href.endswith(item_name)):
|
||||||
|
content = item.get_content()
|
||||||
|
soup = BeautifulSoup(content, "html.parser")
|
||||||
|
# Clean up — remove body/html/head wrappers, keep inner content
|
||||||
|
body = soup.find("body")
|
||||||
|
if body:
|
||||||
|
return str(body)
|
||||||
|
return str(soup)
|
||||||
|
return ""
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to fetch EPUB chapter content for %s", chapter.href)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["GET", "PATCH"])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def book_reading_settings_view(request: Request) -> Response:
|
||||||
|
"""Get or update reading settings via the books API contract."""
|
||||||
|
settings, _created = ReadingSettings.objects.get_or_create(user=request.user)
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
serializer = BookReadingSettingsSerializer(settings)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
serializer = BookReadingSettingsSerializer(settings, data=request.data, partial=True)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ReaderConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.reader"
|
||||||
|
verbose_name = "Reader Settings"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2026-05-29 06:29
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '__first__'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ReadingSettings',
|
||||||
|
fields=[
|
||||||
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='reading_settings', serialize=False, to=settings.AUTH_USER_MODEL)),
|
||||||
|
('font_family', models.CharField(choices=[('sans-serif', 'Sans-serif'), ('serif', 'Serif'), ('monospace', 'Monospace')], default='serif', max_length=32)),
|
||||||
|
('font_size', models.PositiveSmallIntegerField(default=18)),
|
||||||
|
('line_height', models.FloatField(default=1.6)),
|
||||||
|
('margin_width', models.PositiveSmallIntegerField(default=16)),
|
||||||
|
('background_color', models.CharField(default='#f5f0eb', max_length=7)),
|
||||||
|
('text_color', models.CharField(default='#1a1a1a', max_length=7)),
|
||||||
|
('brightness', models.PositiveSmallIntegerField(default=100)),
|
||||||
|
('orientation_lock', models.CharField(choices=[('auto', 'Auto'), ('portrait', 'Portrait'), ('landscape', 'Landscape')], default='auto', max_length=16)),
|
||||||
|
('theme', models.CharField(choices=[('sepia', 'Sepia'), ('dark', 'Dark'), ('light', 'Light'), ('paper', 'Paper')], default='sepia', max_length=32)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Reading Settings',
|
||||||
|
'verbose_name_plural': 'Reading Settings',
|
||||||
|
'db_table': 'reader_reading_settings',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class ReadingSettings(models.Model):
|
||||||
|
"""Per-user reading preferences for the e-book reader view."""
|
||||||
|
|
||||||
|
THEME_CHOICES = [
|
||||||
|
("sepia", "Sepia"),
|
||||||
|
("dark", "Dark"),
|
||||||
|
("light", "Light"),
|
||||||
|
("paper", "Paper"),
|
||||||
|
]
|
||||||
|
|
||||||
|
FONT_CHOICES = [
|
||||||
|
("sans-serif", "Sans-serif"),
|
||||||
|
("serif", "Serif"),
|
||||||
|
("monospace", "Monospace"),
|
||||||
|
]
|
||||||
|
|
||||||
|
ORIENTATION_CHOICES = [
|
||||||
|
("auto", "Auto"),
|
||||||
|
("portrait", "Portrait"),
|
||||||
|
("landscape", "Landscape"),
|
||||||
|
]
|
||||||
|
|
||||||
|
user = models.OneToOneField(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="reading_settings",
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
font_family = models.CharField(max_length=32, choices=FONT_CHOICES, default="serif")
|
||||||
|
font_size = models.PositiveSmallIntegerField(default=18)
|
||||||
|
line_height = models.FloatField(default=1.6)
|
||||||
|
margin_width = models.PositiveSmallIntegerField(default=16)
|
||||||
|
background_color = models.CharField(max_length=7, default="#f5f0eb")
|
||||||
|
text_color = models.CharField(max_length=7, default="#1a1a1a")
|
||||||
|
brightness = models.PositiveSmallIntegerField(default=100)
|
||||||
|
orientation_lock = models.CharField(
|
||||||
|
max_length=16, choices=ORIENTATION_CHOICES, default="auto"
|
||||||
|
)
|
||||||
|
theme = models.CharField(max_length=32, choices=THEME_CHOICES, default="sepia")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "reader_reading_settings"
|
||||||
|
verbose_name = "Reading Settings"
|
||||||
|
verbose_name_plural = "Reading Settings"
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.user} — {self.theme} ({self.font_size}px)"
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.reader.models import ReadingSettings
|
||||||
|
|
||||||
|
# Theme presets mapped to colors
|
||||||
|
THEME_COLORS = {
|
||||||
|
"sepia": {"background_color": "#f5f0eb", "text_color": "#1a1a1a"},
|
||||||
|
"dark": {"background_color": "#1a1a2e", "text_color": "#e0e0e0"},
|
||||||
|
"light": {"background_color": "#ffffff", "text_color": "#1a1a1a"},
|
||||||
|
"paper": {"background_color": "#e8e0d4", "text_color": "#2c2c2c"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ReadingSettingsSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serialize ReadingSettings for the current user."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ReadingSettings
|
||||||
|
fields = [
|
||||||
|
"font_family",
|
||||||
|
"font_size",
|
||||||
|
"line_height",
|
||||||
|
"margin_width",
|
||||||
|
"background_color",
|
||||||
|
"text_color",
|
||||||
|
"brightness",
|
||||||
|
"orientation_lock",
|
||||||
|
"theme",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
read_only_fields = ["created_at", "updated_at"]
|
||||||
|
|
||||||
|
def validate_font_size(self, value: int) -> int:
|
||||||
|
if value < 12 or value > 32:
|
||||||
|
raise serializers.ValidationError("Font size must be between 12 and 32.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_line_height(self, value: float) -> float:
|
||||||
|
if value < 1.2 or value > 2.0:
|
||||||
|
raise serializers.ValidationError("Line height must be between 1.2 and 2.0.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_margin_width(self, value: int) -> int:
|
||||||
|
if value < 8 or value > 48:
|
||||||
|
raise serializers.ValidationError("Margin width must be between 8 and 48.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_brightness(self, value: int) -> int:
|
||||||
|
if value < 0 or value > 100:
|
||||||
|
raise serializers.ValidationError("Brightness must be between 0 and 100.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
"""Sync theme colors when theme changes, unless explicit colors provided."""
|
||||||
|
theme = attrs.get("theme")
|
||||||
|
if theme and theme in THEME_COLORS:
|
||||||
|
# Only auto-set colors if not explicitly provided
|
||||||
|
if "background_color" not in attrs:
|
||||||
|
attrs["background_color"] = THEME_COLORS[theme]["background_color"]
|
||||||
|
if "text_color" not in attrs:
|
||||||
|
attrs["text_color"] = THEME_COLORS[theme]["text_color"]
|
||||||
|
return attrs
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.reader.views import reading_settings_view
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("settings/", reading_settings_view, name="reading-settings"),
|
||||||
|
]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from rest_framework import permissions, status
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from apps.reader.models import ReadingSettings
|
||||||
|
from apps.reader.serializers import ReadingSettingsSerializer
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["GET", "PUT", "PATCH"])
|
||||||
|
@permission_classes([permissions.IsAuthenticated])
|
||||||
|
def reading_settings_view(request: Request) -> Response:
|
||||||
|
"""Get or update the current user's reading settings.
|
||||||
|
|
||||||
|
GET → return existing settings (auto-create defaults if missing)
|
||||||
|
PUT → create or fully replace settings
|
||||||
|
PATCH → partial update
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
settings, created = ReadingSettings.objects.get_or_create(user=user)
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
serializer = ReadingSettingsSerializer(settings)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
if request.method == "PUT":
|
||||||
|
serializer = ReadingSettingsSerializer(settings, data=request.data)
|
||||||
|
elif request.method == "PATCH":
|
||||||
|
serializer = ReadingSettingsSerializer(settings, data=request.data, partial=True)
|
||||||
|
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||||
|
|
||||||
|
from apps.users.models import User
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(User)
|
||||||
|
class UserAdmin(BaseUserAdmin):
|
||||||
|
"""Admin config for the custom User model."""
|
||||||
|
list_display = ("email", "username", "is_staff", "is_active", "date_joined")
|
||||||
|
search_fields = ("email", "username")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class UsersConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.users"
|
||||||
|
label = "users"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2026-06-03 22:26
|
||||||
|
|
||||||
|
import django.contrib.auth.models
|
||||||
|
import django.contrib.auth.validators
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('auth', '0012_alter_user_first_name_max_length'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='User',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||||
|
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||||
|
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||||
|
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||||
|
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||||
|
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||||
|
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||||
|
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||||
|
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||||
|
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||||
|
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||||
|
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'User',
|
||||||
|
'verbose_name_plural': 'Users',
|
||||||
|
'db_table': 'users_user',
|
||||||
|
},
|
||||||
|
managers=[
|
||||||
|
('objects', django.contrib.auth.models.UserManager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from django.contrib.auth.models import AbstractUser
|
||||||
|
|
||||||
|
|
||||||
|
class User(AbstractUser):
|
||||||
|
"""Custom user model. Uses email as the unique identifier field."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "users_user"
|
||||||
|
verbose_name = "User"
|
||||||
|
verbose_name_plural = "Users"
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.email or self.username
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.password_validation import validate_password
|
||||||
|
from rest_framework import serializers
|
||||||
|
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_username(email: str) -> str:
|
||||||
|
local = email.split("@", 1)[0]
|
||||||
|
candidate = re.sub(r"[^\w.@+-]", "_", local).strip("._")
|
||||||
|
return candidate[:150] if candidate else "user"
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_username(base: str) -> str:
|
||||||
|
username = base[:150]
|
||||||
|
if not User.objects.filter(username=username).exists():
|
||||||
|
return username
|
||||||
|
suffix = 1
|
||||||
|
while User.objects.filter(username=f"{username[:140]}_{suffix}").exists():
|
||||||
|
suffix += 1
|
||||||
|
return f"{username[:140]}_{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterSerializer(serializers.Serializer):
|
||||||
|
email = serializers.EmailField()
|
||||||
|
password = serializers.CharField(write_only=True, min_length=8)
|
||||||
|
|
||||||
|
def validate_email(self, value: str) -> str:
|
||||||
|
email = value.lower()
|
||||||
|
if User.objects.filter(email__iexact=email).exists():
|
||||||
|
raise serializers.ValidationError("A user with this email already exists.")
|
||||||
|
return email
|
||||||
|
|
||||||
|
def validate_password(self, value: str) -> str:
|
||||||
|
validate_password(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def create(self, validated_data: dict) -> User:
|
||||||
|
email = validated_data["email"]
|
||||||
|
username = _unique_username(_derive_username(email))
|
||||||
|
return User.objects.create_user(
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
password=validated_data["password"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_representation(self, instance: User) -> dict:
|
||||||
|
return {
|
||||||
|
"id": instance.id,
|
||||||
|
"email": instance.email,
|
||||||
|
"username": instance.username,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EmailTokenObtainPairSerializer(TokenObtainPairSerializer):
|
||||||
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields.pop(self.username_field, None)
|
||||||
|
self.fields["email"] = serializers.EmailField(required=True)
|
||||||
|
|
||||||
|
def validate(self, attrs: dict) -> dict:
|
||||||
|
email = attrs.get("email", "").lower()
|
||||||
|
password = attrs.get("password")
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = User.objects.get(email__iexact=email)
|
||||||
|
except User.DoesNotExist as exc:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"detail": "No active account found with the given credentials."}
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not user.check_password(password):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"detail": "No active account found with the given credentials."}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
raise serializers.ValidationError({"detail": "User account is disabled."})
|
||||||
|
|
||||||
|
refresh = self.get_token(user)
|
||||||
|
return {
|
||||||
|
"refresh": str(refresh),
|
||||||
|
"access": str(refresh.access_token),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_token(cls, user: User) -> object:
|
||||||
|
token = super().get_token(user)
|
||||||
|
token["email"] = user.email
|
||||||
|
return token
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from rest_framework_simplejwt.views import TokenRefreshView
|
||||||
|
|
||||||
|
from apps.users.views import EmailTokenObtainPairView, RegisterView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("register/", RegisterView.as_view(), name="register"),
|
||||||
|
path("token/", EmailTokenObtainPairView.as_view(), name="token_obtain_pair"),
|
||||||
|
path("login/", EmailTokenObtainPairView.as_view(), name="login"),
|
||||||
|
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
|
||||||
|
]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from rest_framework import generics
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
|
||||||
|
from apps.users.serializers import EmailTokenObtainPairSerializer, RegisterSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterView(generics.CreateAPIView):
|
||||||
|
serializer_class = RegisterSerializer
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
|
||||||
|
class EmailTokenObtainPairView(TokenObtainPairView):
|
||||||
|
serializer_class = EmailTokenObtainPairSerializer
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
from books.models import Book
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Book)
|
|
||||||
class BookAdmin(admin.ModelAdmin):
|
|
||||||
"""Admin configuration for the Book model."""
|
|
||||||
|
|
||||||
list_display = ["title", "author", "genre", "reading_status", "reading_progress", "owner", "updated_at"]
|
|
||||||
list_filter = ["reading_status", "genre"]
|
|
||||||
search_fields = ["title", "author"]
|
|
||||||
readonly_fields = ["reading_progress", "created_at", "updated_at"]
|
|
||||||
raw_id_fields = ["owner"]
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class BooksConfig(AppConfig):
|
|
||||||
name = 'books'
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# Generated by Django 6.0.5 on 2026-05-26 00:49
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Book',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('title', models.CharField(max_length=500, verbose_name='Title')),
|
|
||||||
('author', models.CharField(max_length=500, verbose_name='Author')),
|
|
||||||
('genre', models.CharField(blank=True, default='', max_length=200, verbose_name='Genre')),
|
|
||||||
('description', models.TextField(blank=True, default='', verbose_name='Description')),
|
|
||||||
('cover_image_url', models.URLField(blank=True, default='', verbose_name='Cover Image URL')),
|
|
||||||
('isbn', models.CharField(blank=True, default='', max_length=20, verbose_name='ISBN')),
|
|
||||||
('total_pages', models.PositiveIntegerField(default=0, verbose_name='Total Pages')),
|
|
||||||
('current_page', models.PositiveIntegerField(default=0, verbose_name='Current Page')),
|
|
||||||
('reading_status', models.CharField(choices=[('not_started', 'Not Started'), ('reading', 'Reading'), ('finished', 'Finished'), ('dnf', 'Did Not Finish')], default='not_started', max_length=20, verbose_name='Reading Status')),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
|
|
||||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='books', to=settings.AUTH_USER_MODEL, verbose_name='Owner')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Book',
|
|
||||||
'verbose_name_plural': 'Books',
|
|
||||||
'ordering': ['-updated_at'],
|
|
||||||
'indexes': [models.Index(fields=['owner', 'reading_status'], name='books_book_owner_i_424622_idx'), models.Index(fields=['owner', 'title'], name='books_book_owner_i_350794_idx'), models.Index(fields=['owner', 'author'], name='books_book_owner_i_f90626_idx')],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.db import models
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
|
|
||||||
class ReadingStatus(models.TextChoices):
|
|
||||||
"""Enumeration of possible reading statuses for a book."""
|
|
||||||
|
|
||||||
NOT_STARTED = "not_started", _("Not Started")
|
|
||||||
READING = "reading", _("Reading")
|
|
||||||
FINISHED = "finished", _("Finished")
|
|
||||||
DNF = "dnf", _("Did Not Finish")
|
|
||||||
|
|
||||||
|
|
||||||
class Book(models.Model):
|
|
||||||
"""Represents a book in the user's library."""
|
|
||||||
|
|
||||||
title = models.CharField(max_length=500, verbose_name=_("Title"))
|
|
||||||
author = models.CharField(max_length=500, verbose_name=_("Author"))
|
|
||||||
genre = models.CharField(max_length=200, blank=True, default="", verbose_name=_("Genre"))
|
|
||||||
description = models.TextField(blank=True, default="", verbose_name=_("Description"))
|
|
||||||
cover_image_url = models.URLField(blank=True, default="", verbose_name=_("Cover Image URL"))
|
|
||||||
isbn = models.CharField(max_length=20, blank=True, default="", verbose_name=_("ISBN"))
|
|
||||||
total_pages = models.PositiveIntegerField(default=0, verbose_name=_("Total Pages"))
|
|
||||||
current_page = models.PositiveIntegerField(default=0, verbose_name=_("Current Page"))
|
|
||||||
reading_status = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
choices=ReadingStatus.choices,
|
|
||||||
default=ReadingStatus.NOT_STARTED,
|
|
||||||
verbose_name=_("Reading Status"),
|
|
||||||
)
|
|
||||||
owner = models.ForeignKey(
|
|
||||||
"auth.User",
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="books",
|
|
||||||
verbose_name=_("Owner"),
|
|
||||||
)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
|
|
||||||
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["-updated_at"]
|
|
||||||
verbose_name = _("Book")
|
|
||||||
verbose_name_plural = _("Books")
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=["owner", "reading_status"]),
|
|
||||||
models.Index(fields=["owner", "title"]),
|
|
||||||
models.Index(fields=["owner", "author"]),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"{self.title} by {self.author}"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def reading_progress(self) -> float:
|
|
||||||
"""Calculate reading progress as a percentage (0.0 - 100.0)."""
|
|
||||||
if self.total_pages == 0:
|
|
||||||
return 0.0
|
|
||||||
return round((self.current_page / self.total_pages) * 100, 1)
|
|
||||||
|
|
||||||
def mark_as_finished(self) -> None:
|
|
||||||
"""Mark the book as finished, setting progress to 100%."""
|
|
||||||
self.reading_status = ReadingStatus.FINISHED
|
|
||||||
self.current_page = self.total_pages
|
|
||||||
self.save(update_fields=["reading_status", "current_page", "updated_at"])
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
from books.models import Book, ReadingStatus
|
|
||||||
|
|
||||||
|
|
||||||
class BookListSerializer(serializers.ModelSerializer):
|
|
||||||
"""Lightweight serializer for list views — avoids heavy field serialization."""
|
|
||||||
|
|
||||||
reading_progress = serializers.FloatField(read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Book
|
|
||||||
fields = [
|
|
||||||
"id",
|
|
||||||
"title",
|
|
||||||
"author",
|
|
||||||
"genre",
|
|
||||||
"reading_status",
|
|
||||||
"reading_progress",
|
|
||||||
"cover_image_url",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class BookDetailSerializer(serializers.ModelSerializer):
|
|
||||||
"""Full serializer for book detail views including all metadata."""
|
|
||||||
|
|
||||||
reading_progress = serializers.FloatField(read_only=True)
|
|
||||||
owner = serializers.ReadOnlyField(source="owner.username")
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Book
|
|
||||||
fields = [
|
|
||||||
"id",
|
|
||||||
"title",
|
|
||||||
"author",
|
|
||||||
"genre",
|
|
||||||
"description",
|
|
||||||
"cover_image_url",
|
|
||||||
"isbn",
|
|
||||||
"total_pages",
|
|
||||||
"current_page",
|
|
||||||
"reading_status",
|
|
||||||
"reading_progress",
|
|
||||||
"owner",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
]
|
|
||||||
read_only_fields = ["owner", "created_at", "updated_at", "reading_progress"]
|
|
||||||
|
|
||||||
def validate_title(self, value: str) -> str:
|
|
||||||
"""Ensure title is not just whitespace."""
|
|
||||||
stripped = value.strip()
|
|
||||||
if not stripped:
|
|
||||||
msg = "Title cannot be empty."
|
|
||||||
raise serializers.ValidationError(msg)
|
|
||||||
return stripped
|
|
||||||
|
|
||||||
def validate_author(self, value: str) -> str:
|
|
||||||
"""Ensure author is not just whitespace."""
|
|
||||||
stripped = value.strip()
|
|
||||||
if not stripped:
|
|
||||||
msg = "Author cannot be empty."
|
|
||||||
raise serializers.ValidationError(msg)
|
|
||||||
return stripped
|
|
||||||
|
|
||||||
def validate(self, attrs: dict) -> dict:
|
|
||||||
"""Business rules — handles both full creates and partial updates."""
|
|
||||||
# Resolve effective values: use provided attrs, fall back to instance
|
|
||||||
current_total = getattr(self.instance, "total_pages", None)
|
|
||||||
current_current = getattr(self.instance, "current_page", None)
|
|
||||||
total_pages = attrs.get("total_pages", current_total) or 0
|
|
||||||
current_page = attrs.get("current_page", current_current) or 0
|
|
||||||
reading_status = attrs.get("reading_status", None)
|
|
||||||
|
|
||||||
if current_page > total_pages > 0:
|
|
||||||
msg = "Current page cannot exceed total pages."
|
|
||||||
raise serializers.ValidationError({"current_page": msg})
|
|
||||||
|
|
||||||
if reading_status == ReadingStatus.FINISHED:
|
|
||||||
if total_pages > 0:
|
|
||||||
attrs["current_page"] = total_pages
|
|
||||||
elif self.instance and self.instance.total_pages > 0:
|
|
||||||
attrs["current_page"] = self.instance.total_pages
|
|
||||||
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
|
|
||||||
class BookWriteSerializer(BookDetailSerializer):
|
|
||||||
"""Alias for detail serializer — used for write operations with full validation."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
"""Tests for the books app — API endpoints, models, serializers, and permissions."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.test import TestCase, override_settings
|
|
||||||
from rest_framework import status
|
|
||||||
from rest_framework.test import APIClient
|
|
||||||
|
|
||||||
from books.models import Book, ReadingStatus
|
|
||||||
|
|
||||||
|
|
||||||
class BookModelTests(TestCase):
|
|
||||||
"""Tests for the Book model."""
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.user = User.objects.create_user(username="testuser", password="testpass123")
|
|
||||||
self.book = Book.objects.create(
|
|
||||||
title="Test Book",
|
|
||||||
author="Test Author",
|
|
||||||
total_pages=200,
|
|
||||||
current_page=50,
|
|
||||||
reading_status=ReadingStatus.READING,
|
|
||||||
owner=self.user,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_reading_progress_calculates_correctly(self) -> None:
|
|
||||||
"""Reading progress should be (current_page / total_pages) * 100."""
|
|
||||||
assert self.book.reading_progress == 25.0
|
|
||||||
|
|
||||||
def test_reading_progress_returns_zero_when_no_pages(self) -> None:
|
|
||||||
"""When total_pages is 0, reading_progress should be 0.0."""
|
|
||||||
book = Book.objects.create(
|
|
||||||
title="No Pages",
|
|
||||||
author="Author",
|
|
||||||
total_pages=0,
|
|
||||||
current_page=50,
|
|
||||||
owner=self.user,
|
|
||||||
)
|
|
||||||
assert book.reading_progress == 0.0
|
|
||||||
|
|
||||||
def test_mark_as_finished_sets_progress_to_100(self) -> None:
|
|
||||||
"""mark_as_finished should set status to FINISHED and progress to 100%."""
|
|
||||||
self.book.mark_as_finished()
|
|
||||||
self.book.refresh_from_db()
|
|
||||||
assert self.book.reading_status == ReadingStatus.FINISHED
|
|
||||||
assert self.book.current_page == self.book.total_pages
|
|
||||||
|
|
||||||
def test_mark_as_finished_with_zero_pages(self) -> None:
|
|
||||||
"""mark_as_finished with total_pages=0 should set 0/0."""
|
|
||||||
book = Book.objects.create(
|
|
||||||
title="Empty",
|
|
||||||
author="Author",
|
|
||||||
total_pages=0,
|
|
||||||
current_page=0,
|
|
||||||
owner=self.user,
|
|
||||||
)
|
|
||||||
book.mark_as_finished()
|
|
||||||
book.refresh_from_db()
|
|
||||||
assert book.reading_status == ReadingStatus.FINISHED
|
|
||||||
assert book.current_page == 0
|
|
||||||
|
|
||||||
def test_str_method(self) -> None:
|
|
||||||
"""__str__ should return 'Title by Author'."""
|
|
||||||
assert str(self.book) == "Test Book by Test Author"
|
|
||||||
|
|
||||||
|
|
||||||
class BookAPITests(TestCase):
|
|
||||||
"""Tests for the Book API endpoints."""
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.client = APIClient()
|
|
||||||
self.user = User.objects.create_user(username="testuser", password="testpass123")
|
|
||||||
self.other_user = User.objects.create_user(username="other", password="testpass123")
|
|
||||||
self.client.force_authenticate(user=self.user)
|
|
||||||
|
|
||||||
self.book = Book.objects.create(
|
|
||||||
title="My Book",
|
|
||||||
author="My Author",
|
|
||||||
genre="Fiction",
|
|
||||||
description="A great book",
|
|
||||||
total_pages=200,
|
|
||||||
current_page=50,
|
|
||||||
reading_status=ReadingStatus.READING,
|
|
||||||
owner=self.user,
|
|
||||||
)
|
|
||||||
# Other user's book (should not be visible)
|
|
||||||
Book.objects.create(
|
|
||||||
title="Other Book",
|
|
||||||
author="Other Author",
|
|
||||||
total_pages=100,
|
|
||||||
owner=self.other_user,
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- List ---
|
|
||||||
|
|
||||||
def test_list_books_returns_owned_books_only(self) -> None:
|
|
||||||
"""GET /api/books/ should only return books owned by the current user."""
|
|
||||||
resp = self.client.get("/api/books/")
|
|
||||||
assert resp.status_code == status.HTTP_200_OK
|
|
||||||
assert resp.data["count"] == 1
|
|
||||||
assert resp.data["results"][0]["title"] == "My Book"
|
|
||||||
|
|
||||||
def test_list_books_requires_authentication(self) -> None:
|
|
||||||
"""GET /api/books/ without auth should return 403."""
|
|
||||||
self.client.force_authenticate(user=None)
|
|
||||||
resp = self.client.get("/api/books/")
|
|
||||||
assert resp.status_code == status.HTTP_403_FORBIDDEN
|
|
||||||
|
|
||||||
# --- Create ---
|
|
||||||
|
|
||||||
def test_create_book_sets_owner(self) -> None:
|
|
||||||
"""POST /api/books/ should create a book owned by the current user."""
|
|
||||||
resp = self.client.post("/api/books/", {
|
|
||||||
"title": "New Book",
|
|
||||||
"author": "New Author",
|
|
||||||
}, format="json")
|
|
||||||
assert resp.status_code == status.HTTP_201_CREATED
|
|
||||||
assert resp.data["owner"] == "testuser"
|
|
||||||
assert Book.objects.filter(title="New Book", owner=self.user).exists()
|
|
||||||
|
|
||||||
def test_create_book_validates_required_fields(self) -> None:
|
|
||||||
"""POST /api/books/ with missing title should fail."""
|
|
||||||
resp = self.client.post("/api/books/", {
|
|
||||||
"author": "Author Only",
|
|
||||||
}, format="json")
|
|
||||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
|
||||||
|
|
||||||
# --- Retrieve ---
|
|
||||||
|
|
||||||
def test_retrieve_book(self) -> None:
|
|
||||||
"""GET /api/books/{id}/ should return full book details."""
|
|
||||||
resp = self.client.get(f"/api/books/{self.book.id}/")
|
|
||||||
assert resp.status_code == status.HTTP_200_OK
|
|
||||||
assert resp.data["title"] == "My Book"
|
|
||||||
assert resp.data["reading_progress"] == 25.0
|
|
||||||
assert resp.data["owner"] == "testuser"
|
|
||||||
|
|
||||||
def test_retrieve_other_users_book_returns_404(self) -> None:
|
|
||||||
"""GET /api/books/{other_id}/ should return 404 for another user's book."""
|
|
||||||
other_book = Book.objects.get(title="Other Book")
|
|
||||||
resp = self.client.get(f"/api/books/{other_book.id}/")
|
|
||||||
assert resp.status_code == status.HTTP_404_NOT_FOUND
|
|
||||||
|
|
||||||
# --- Update ---
|
|
||||||
|
|
||||||
def test_update_book(self) -> None:
|
|
||||||
"""PATCH /api/books/{id}/ should update book fields."""
|
|
||||||
resp = self.client.patch(f"/api/books/{self.book.id}/", {
|
|
||||||
"title": "Updated Title",
|
|
||||||
"current_page": 100,
|
|
||||||
}, format="json")
|
|
||||||
assert resp.status_code == status.HTTP_200_OK
|
|
||||||
assert resp.data["title"] == "Updated Title"
|
|
||||||
assert resp.data["reading_progress"] == 50.0
|
|
||||||
|
|
||||||
def test_update_current_page_exceeds_total(self) -> None:
|
|
||||||
"""PATCH with current_page > total_pages should fail."""
|
|
||||||
resp = self.client.patch(f"/api/books/{self.book.id}/", {
|
|
||||||
"current_page": 999,
|
|
||||||
}, format="json")
|
|
||||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
|
||||||
|
|
||||||
# --- Delete ---
|
|
||||||
|
|
||||||
def test_delete_book(self) -> None:
|
|
||||||
"""DELETE /api/books/{id}/ should delete the book."""
|
|
||||||
resp = self.client.delete(f"/api/books/{self.book.id}/")
|
|
||||||
assert resp.status_code == status.HTTP_204_NO_CONTENT
|
|
||||||
assert not Book.objects.filter(id=self.book.id).exists()
|
|
||||||
|
|
||||||
# --- Mark Finished ---
|
|
||||||
|
|
||||||
def test_mark_finished_sets_100_percent(self) -> None:
|
|
||||||
"""POST /api/books/{id}/mark_finished/ should set progress to 100%."""
|
|
||||||
resp = self.client.post(f"/api/books/{self.book.id}/mark_finished/")
|
|
||||||
assert resp.status_code == status.HTTP_200_OK
|
|
||||||
assert resp.data["reading_status"] == ReadingStatus.FINISHED
|
|
||||||
assert resp.data["reading_progress"] == 100.0
|
|
||||||
|
|
||||||
def test_mark_finished_idempotent(self) -> None:
|
|
||||||
"""Calling mark_finished twice should be safe."""
|
|
||||||
self.client.post(f"/api/books/{self.book.id}/mark_finished/")
|
|
||||||
resp = self.client.post(f"/api/books/{self.book.id}/mark_finished/")
|
|
||||||
assert resp.status_code == status.HTTP_200_OK
|
|
||||||
|
|
||||||
def test_mark_finished_other_users_book(self) -> None:
|
|
||||||
"""POST mark_finished on another user's book should return 404."""
|
|
||||||
other_book = Book.objects.get(title="Other Book")
|
|
||||||
resp = self.client.post(f"/api/books/{other_book.id}/mark_finished/")
|
|
||||||
assert resp.status_code == status.HTTP_404_NOT_FOUND
|
|
||||||
|
|
||||||
# --- Stats ---
|
|
||||||
|
|
||||||
def test_stats_returns_counts(self) -> None:
|
|
||||||
"""GET /api/books/stats/ should return aggregate counts."""
|
|
||||||
resp = self.client.get("/api/books/stats/")
|
|
||||||
assert resp.status_code == status.HTTP_200_OK
|
|
||||||
assert resp.data["total_books"] == 1
|
|
||||||
assert resp.data["finished"] == 0
|
|
||||||
assert resp.data["reading"] == 1
|
|
||||||
assert resp.data["not_started"] == 0
|
|
||||||
|
|
||||||
# --- Filtering & Sorting ---
|
|
||||||
|
|
||||||
def test_filter_by_reading_status(self) -> None:
|
|
||||||
"""GET /api/books/?reading_status=reading should filter correctly."""
|
|
||||||
resp = self.client.get("/api/books/", {"reading_status": "reading"})
|
|
||||||
assert resp.status_code == status.HTTP_200_OK
|
|
||||||
assert resp.data["count"] == 1
|
|
||||||
|
|
||||||
resp = self.client.get("/api/books/", {"reading_status": "finished"})
|
|
||||||
assert resp.status_code == status.HTTP_200_OK
|
|
||||||
assert resp.data["count"] == 0
|
|
||||||
|
|
||||||
def test_search_by_title(self) -> None:
|
|
||||||
"""GET /api/books/?search=My should filter by title."""
|
|
||||||
resp = self.client.get("/api/books/", {"search": "My"})
|
|
||||||
assert resp.status_code == status.HTTP_200_OK
|
|
||||||
assert resp.data["count"] == 1
|
|
||||||
|
|
||||||
resp = self.client.get("/api/books/", {"search": "Nonexistent"})
|
|
||||||
assert resp.status_code == status.HTTP_200_OK
|
|
||||||
assert resp.data["count"] == 0
|
|
||||||
|
|
||||||
def test_search_by_author(self) -> None:
|
|
||||||
"""GET /api/books/?search=My should filter by author."""
|
|
||||||
resp = self.client.get("/api/books/", {"search": "My Author"})
|
|
||||||
assert resp.status_code == status.HTTP_200_OK
|
|
||||||
assert resp.data["count"] == 1
|
|
||||||
|
|
||||||
def test_sort_by_title(self) -> None:
|
|
||||||
"""GET /api/books/?sort_by=title should sort alphabetically."""
|
|
||||||
Book.objects.create(title="Aardvark", author="Author", owner=self.user)
|
|
||||||
Book.objects.create(title="Zebra", author="Author", owner=self.user)
|
|
||||||
resp = self.client.get("/api/books/", {"sort_by": "title"})
|
|
||||||
assert resp.status_code == status.HTTP_200_OK
|
|
||||||
titles = [b["title"] for b in resp.data["results"]]
|
|
||||||
assert titles == sorted(titles)
|
|
||||||
|
|
||||||
def test_sort_by_author_desc(self) -> None:
|
|
||||||
"""GET /api/books/?sort_by=-author should sort by author desc."""
|
|
||||||
resp = self.client.get("/api/books/", {"sort_by": "-author"})
|
|
||||||
assert resp.status_code == status.HTTP_200_OK
|
|
||||||
|
|
||||||
# --- Pagination ---
|
|
||||||
|
|
||||||
def test_pagination_default_page_size(self) -> None:
|
|
||||||
"""Books should be paginated with default page size."""
|
|
||||||
for i in range(25):
|
|
||||||
Book.objects.create(
|
|
||||||
title=f"Book {i}",
|
|
||||||
author="Author",
|
|
||||||
owner=self.user,
|
|
||||||
)
|
|
||||||
resp = self.client.get("/api/books/")
|
|
||||||
assert resp.status_code == status.HTTP_200_OK
|
|
||||||
assert len(resp.data["results"]) == 20 # PAGE_SIZE = 20
|
|
||||||
assert resp.data["count"] == 26 # 1 original + 25 new
|
|
||||||
|
|
||||||
def test_pagination_second_page(self) -> None:
|
|
||||||
"""GET /api/books/?page=2 should return remaining items."""
|
|
||||||
for i in range(25):
|
|
||||||
Book.objects.create(
|
|
||||||
title=f"Book {i}",
|
|
||||||
author="Author",
|
|
||||||
owner=self.user,
|
|
||||||
)
|
|
||||||
resp = self.client.get("/api/books/", {"page": 2})
|
|
||||||
assert resp.status_code == status.HTTP_200_OK
|
|
||||||
assert len(resp.data["results"]) == 6
|
|
||||||
|
|
||||||
|
|
||||||
class BookSerializerTests(TestCase):
|
|
||||||
"""Tests for serializers — validation rules."""
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.user = User.objects.create_user(username="testuser", password="testpass123")
|
|
||||||
|
|
||||||
def test_current_page_cannot_exceed_total(self) -> None:
|
|
||||||
"""Serializer should reject current_page > total_pages."""
|
|
||||||
from books.serializers import BookDetailSerializer
|
|
||||||
data = {
|
|
||||||
"title": "Test",
|
|
||||||
"author": "Author",
|
|
||||||
"total_pages": 100,
|
|
||||||
"current_page": 150,
|
|
||||||
}
|
|
||||||
serializer = BookDetailSerializer(data=data)
|
|
||||||
assert not serializer.is_valid()
|
|
||||||
assert "current_page" in serializer.errors
|
|
||||||
|
|
||||||
def test_title_cannot_be_blank(self) -> None:
|
|
||||||
"""Serializer should reject blank title."""
|
|
||||||
from books.serializers import BookDetailSerializer
|
|
||||||
data = {
|
|
||||||
"title": " ",
|
|
||||||
"author": "Author",
|
|
||||||
}
|
|
||||||
serializer = BookDetailSerializer(data=data)
|
|
||||||
assert not serializer.is_valid()
|
|
||||||
assert "title" in serializer.errors
|
|
||||||
|
|
||||||
def test_author_cannot_be_blank(self) -> None:
|
|
||||||
"""Serializer should reject blank author."""
|
|
||||||
from books.serializers import BookDetailSerializer
|
|
||||||
data = {
|
|
||||||
"title": "Test",
|
|
||||||
"author": "",
|
|
||||||
}
|
|
||||||
serializer = BookDetailSerializer(data=data)
|
|
||||||
assert not serializer.is_valid()
|
|
||||||
assert "author" in serializer.errors
|
|
||||||
|
|
||||||
def test_list_serializer_has_minimal_fields(self) -> None:
|
|
||||||
"""BookListSerializer should exclude sensitive/fluff fields."""
|
|
||||||
from books.serializers import BookListSerializer
|
|
||||||
serializer = BookListSerializer()
|
|
||||||
fields = set(serializer.fields.keys())
|
|
||||||
assert "id" in fields
|
|
||||||
assert "title" in fields
|
|
||||||
assert "author" in fields
|
|
||||||
assert "reading_progress" in fields
|
|
||||||
assert "description" not in fields
|
|
||||||
assert "isbn" not in fields
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.urls import include, path
|
|
||||||
from rest_framework.routers import DefaultRouter
|
|
||||||
|
|
||||||
from books.views import BookViewSet
|
|
||||||
|
|
||||||
router = DefaultRouter()
|
|
||||||
router.register(r"books", BookViewSet, basename="book")
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path("", include(router.urls)),
|
|
||||||
]
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.db import models
|
|
||||||
from django.db.models import 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 books.models import Book, ReadingStatus
|
|
||||||
from books.serializers import BookDetailSerializer, BookListSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class BookViewSet(viewsets.ModelViewSet):
|
|
||||||
"""
|
|
||||||
ViewSet for managing books in the user's library.
|
|
||||||
|
|
||||||
Provides:
|
|
||||||
- list / retrieve / create / update / partial_update / destroy
|
|
||||||
- `mark_finished` action to set a book as 100% complete
|
|
||||||
- `stats` action for library overview counts
|
|
||||||
"""
|
|
||||||
|
|
||||||
permission_classes = [IsAuthenticated]
|
|
||||||
|
|
||||||
def get_serializer_class(self) -> type:
|
|
||||||
if self.action == "list":
|
|
||||||
return BookListSerializer
|
|
||||||
return BookDetailSerializer
|
|
||||||
|
|
||||||
def get_queryset(self) -> QuerySet[Book]:
|
|
||||||
"""Return books owned by the current user with optimised queries."""
|
|
||||||
qs = Book.objects.filter(owner=self.request.user).select_related("owner")
|
|
||||||
|
|
||||||
# Sorting
|
|
||||||
sort_by = self.request.query_params.get("sort_by", "-updated_at")
|
|
||||||
allowed_sorts = {
|
|
||||||
"title": "title",
|
|
||||||
"-title": "-title",
|
|
||||||
"author": "author",
|
|
||||||
"-author": "-author",
|
|
||||||
"created_at": "created_at",
|
|
||||||
"-created_at": "-created_at",
|
|
||||||
"updated_at": "updated_at",
|
|
||||||
"-updated_at": "-updated_at",
|
|
||||||
"reading_progress": "current_page", # approximate sort by pages read
|
|
||||||
"-reading_progress": "-current_page",
|
|
||||||
}
|
|
||||||
if sort_by in allowed_sorts:
|
|
||||||
qs = qs.order_by(allowed_sorts[sort_by])
|
|
||||||
|
|
||||||
# Filtering
|
|
||||||
status_filter = self.request.query_params.get("reading_status", None)
|
|
||||||
if status_filter in ReadingStatus.values:
|
|
||||||
qs = qs.filter(reading_status=status_filter)
|
|
||||||
|
|
||||||
search = self.request.query_params.get("search", "").strip()
|
|
||||||
if search:
|
|
||||||
qs = qs.filter(
|
|
||||||
models.Q(title__icontains=search) | models.Q(author__icontains=search)
|
|
||||||
)
|
|
||||||
|
|
||||||
return qs
|
|
||||||
|
|
||||||
def perform_create(self, serializer: BookDetailSerializer) -> None:
|
|
||||||
"""Set the owner to the current user on creation."""
|
|
||||||
serializer.save(owner=self.request.user)
|
|
||||||
|
|
||||||
@action(detail=True, methods=["post"])
|
|
||||||
def mark_finished(self, request: Request, pk: int | None = None) -> Response:
|
|
||||||
"""Mark a book as finished (100% progress)."""
|
|
||||||
book: Book = self.get_object()
|
|
||||||
book.mark_as_finished()
|
|
||||||
serializer = self.get_serializer(book)
|
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"])
|
|
||||||
def stats(self, request: Request) -> Response:
|
|
||||||
"""Return aggregate stats about the user's library."""
|
|
||||||
qs = self.get_queryset()
|
|
||||||
total = qs.count()
|
|
||||||
finished = qs.filter(reading_status=ReadingStatus.FINISHED).count()
|
|
||||||
reading = qs.filter(reading_status=ReadingStatus.READING).count()
|
|
||||||
not_started = qs.filter(reading_status=ReadingStatus.NOT_STARTED).count()
|
|
||||||
return Response({
|
|
||||||
"total_books": total,
|
|
||||||
"finished": finished,
|
|
||||||
"reading": reading,
|
|
||||||
"not_started": not_started,
|
|
||||||
})
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
"""
|
|
||||||
ASGI config for config project.
|
|
||||||
|
|
||||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
|
||||||
|
|
||||||
For more information on this file, see
|
|
||||||
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
|
||||||
|
|
||||||
application = get_asgi_application()
|
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""
|
||||||
|
Django settings for cloud-reader backend.
|
||||||
|
|
||||||
|
Generated using Django 5.1. Customised with pydantic-settings integration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from config.settings import settings
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Build paths
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Security
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SECRET_KEY = settings.DJANGO_SECRET_KEY
|
||||||
|
DEBUG = settings.DJANGO_DEBUG
|
||||||
|
ALLOWED_HOSTS = settings.DJANGO_ALLOWED_HOSTS
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Application definition
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
# Django built-in
|
||||||
|
"django.contrib.admin",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.messages",
|
||||||
|
"django.contrib.staticfiles",
|
||||||
|
# Third-party
|
||||||
|
"rest_framework",
|
||||||
|
"rest_framework_simplejwt",
|
||||||
|
"corsheaders",
|
||||||
|
"django_filters",
|
||||||
|
# Local apps
|
||||||
|
"apps.users",
|
||||||
|
"apps.books",
|
||||||
|
"apps.annotations",
|
||||||
|
"apps.reader",
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"django.middleware.common.CommonMiddleware",
|
||||||
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = "config.urls"
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [],
|
||||||
|
"APP_DIRS": True,
|
||||||
|
"OPTIONS": {
|
||||||
|
"context_processors": [
|
||||||
|
"django.template.context_processors.debug",
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = "config.wsgi.application"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Database
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.postgresql",
|
||||||
|
"NAME": settings.DB_NAME,
|
||||||
|
"USER": settings.DB_USER,
|
||||||
|
"PASSWORD": settings.DB_PASSWORD,
|
||||||
|
"HOST": settings.DB_HOST,
|
||||||
|
"PORT": settings.DB_PORT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auth
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
AUTH_USER_MODEL = "users.User"
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DRF
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||||
|
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||||
|
),
|
||||||
|
"DEFAULT_PERMISSION_CLASSES": (
|
||||||
|
"rest_framework.permissions.IsAuthenticated",
|
||||||
|
),
|
||||||
|
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||||
|
"PAGE_SIZE": 50,
|
||||||
|
"DEFAULT_FILTER_BACKENDS": [
|
||||||
|
"django_filters.rest_framework.DjangoFilterBackend",
|
||||||
|
"rest_framework.filters.OrderingFilter",
|
||||||
|
"rest_framework.filters.SearchFilter",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SimpleJWT
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
SIMPLE_JWT = {
|
||||||
|
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=settings.JWT_ACCESS_TOKEN_LIFETIME_MINUTES),
|
||||||
|
"REFRESH_TOKEN_LIFETIME": timedelta(days=settings.JWT_REFRESH_TOKEN_LIFETIME_DAYS),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CORS
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
CORS_ALLOWED_ORIGINS = settings.CORS_ALLOWED_ORIGINS
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# i18n
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
LANGUAGE_CODE = "en-us"
|
||||||
|
TIME_ZONE = "UTC"
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Static / Media / Uploads
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
STATIC_URL = "static/"
|
||||||
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||||
|
MEDIA_URL = "/media/"
|
||||||
|
MEDIA_ROOT = BASE_DIR / "media"
|
||||||
|
|
||||||
|
# Maximum upload size: 50MB
|
||||||
|
DATA_UPLOAD_MAX_MEMORY_SIZE = 52_428_800
|
||||||
|
FILE_UPLOAD_MAX_MEMORY_SIZE = 52_428_800
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Default primary key
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
+36
-88
@@ -1,99 +1,47 @@
|
|||||||
"""
|
from pydantic_settings import BaseSettings
|
||||||
Django settings for config project.
|
|
||||||
|
|
||||||
Generated by 'django-admin startproject' using Django 6.0.5.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pathlib import Path
|
class Settings(BaseSettings):
|
||||||
|
"""Application settings via pydantic-settings. Reads from env vars and .env."""
|
||||||
|
|
||||||
from config.settings_app import settings
|
# Django
|
||||||
|
DJANGO_SECRET_KEY: str = "django-insecure-change-me-in-production"
|
||||||
|
DJANGO_DEBUG: bool = False
|
||||||
|
DJANGO_ALLOWED_HOSTS: list[str] = ["*"]
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
# PostgreSQL
|
||||||
|
DB_NAME: str = "cloud_reader"
|
||||||
|
DB_USER: str = "postgres"
|
||||||
|
DB_PASSWORD: str = "postgres"
|
||||||
|
DB_HOST: str = "localhost"
|
||||||
|
DB_PORT: int = 5432
|
||||||
|
|
||||||
SECRET_KEY = settings.SECRET_KEY
|
# JWT
|
||||||
DEBUG = settings.DEBUG
|
JWT_ACCESS_TOKEN_LIFETIME_MINUTES: int = 60
|
||||||
ALLOWED_HOSTS = settings.ALLOWED_HOSTS
|
JWT_REFRESH_TOKEN_LIFETIME_DAYS: int = 7
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
# CORS
|
||||||
"django.contrib.admin",
|
CORS_ALLOWED_ORIGINS: list[str] = [
|
||||||
"django.contrib.auth",
|
"http://localhost:5173",
|
||||||
"django.contrib.contenttypes",
|
"http://localhost:3000",
|
||||||
"django.contrib.sessions",
|
]
|
||||||
"django.contrib.messages",
|
|
||||||
"django.contrib.staticfiles",
|
|
||||||
# Third-party
|
|
||||||
"rest_framework",
|
|
||||||
"corsheaders",
|
|
||||||
# Local apps
|
|
||||||
"books",
|
|
||||||
]
|
|
||||||
|
|
||||||
MIDDLEWARE = [
|
# Open Library metadata enrichment
|
||||||
"corsheaders.middleware.CorsMiddleware",
|
OPENLIBRARY_ENABLED: bool = True
|
||||||
"django.middleware.security.SecurityMiddleware",
|
OPENLIBRARY_PREFERRED_LANG: str = "es"
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
OPENLIBRARY_FALLBACK_LANG: str = "en"
|
||||||
"django.middleware.common.CommonMiddleware",
|
OPENLIBRARY_TIMEOUT_SECONDS: float = 10.0
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
OPENLIBRARY_CONNECT_TIMEOUT_SECONDS: float = 15.0
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
OPENLIBRARY_USER_AGENT: str = "CloudReader/1.0 (https://github.com/cloud-reader)"
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
|
||||||
]
|
|
||||||
|
|
||||||
ROOT_URLCONF = "config.urls"
|
@property
|
||||||
|
def DATABASE_URL(self) -> str:
|
||||||
|
return (
|
||||||
|
f"postgresql://{self.DB_USER}:{self.DB_PASSWORD}"
|
||||||
|
f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
|
||||||
|
)
|
||||||
|
|
||||||
TEMPLATES = [
|
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||||
{
|
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
||||||
"DIRS": [],
|
|
||||||
"APP_DIRS": True,
|
|
||||||
"OPTIONS": {
|
|
||||||
"context_processors": [
|
|
||||||
"django.template.context_processors.request",
|
|
||||||
"django.contrib.auth.context_processors.auth",
|
|
||||||
"django.contrib.messages.context_processors.messages",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
WSGI_APPLICATION = "config.wsgi.application"
|
|
||||||
|
|
||||||
DATABASES = {
|
settings = Settings()
|
||||||
"default": {
|
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
|
||||||
"NAME": BASE_DIR / "db.sqlite3",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
|
||||||
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
|
||||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
|
||||||
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
|
||||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
|
||||||
]
|
|
||||||
|
|
||||||
LANGUAGE_CODE = "en-us"
|
|
||||||
TIME_ZONE = "UTC"
|
|
||||||
USE_I18N = True
|
|
||||||
USE_TZ = True
|
|
||||||
|
|
||||||
STATIC_URL = "static/"
|
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
|
||||||
|
|
||||||
# CORS
|
|
||||||
CORS_ALLOWED_ORIGINS = settings.CORS_ALLOWED_ORIGINS
|
|
||||||
CORS_ALLOW_CREDENTIALS = settings.CORS_ALLOW_CREDENTIALS
|
|
||||||
|
|
||||||
# REST Framework
|
|
||||||
REST_FRAMEWORK = {
|
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
|
||||||
"rest_framework.authentication.SessionAuthentication",
|
|
||||||
"rest_framework.authentication.BasicAuthentication",
|
|
||||||
],
|
|
||||||
"DEFAULT_PERMISSION_CLASSES": [
|
|
||||||
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
|
|
||||||
],
|
|
||||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
|
||||||
"PAGE_SIZE": 20,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
"""Application settings loaded from environment variables."""
|
|
||||||
|
|
||||||
DEBUG: bool = True
|
|
||||||
SECRET_KEY: str = "django-insecure-change-me-in-production"
|
|
||||||
DATABASE_URL: str = "sqlite:///db.sqlite3"
|
|
||||||
ALLOWED_HOSTS: list[str] = ["*"]
|
|
||||||
CORS_ALLOWED_ORIGINS: list[str] = [
|
|
||||||
"http://localhost:3000",
|
|
||||||
"http://localhost:5173",
|
|
||||||
"http://127.0.0.1:3000",
|
|
||||||
"http://127.0.0.1:5173",
|
|
||||||
]
|
|
||||||
CORS_ALLOW_CREDENTIALS: bool = True
|
|
||||||
|
|
||||||
BASE_DIR: Path = Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
|
||||||
env_prefix="CR_",
|
|
||||||
env_file=".env",
|
|
||||||
env_file_encoding="utf-8",
|
|
||||||
extra="ignore",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
|
||||||
+10
-2
@@ -1,8 +1,16 @@
|
|||||||
|
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
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("api-auth/", include("rest_framework.urls")),
|
path("api/auth/", include("apps.users.urls")),
|
||||||
path("api/", include("books.urls")),
|
path("api/books/", include("apps.books.urls")),
|
||||||
|
path("api/annotations/", include("apps.annotations.urls")),
|
||||||
|
path("api/reader/", include("apps.reader.urls")),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
from django.conf.urls.static import static
|
||||||
|
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
+1
-10
@@ -1,16 +1,7 @@
|
|||||||
"""
|
|
||||||
WSGI config for config project.
|
|
||||||
|
|
||||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
|
||||||
|
|
||||||
For more information on this file, see
|
|
||||||
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django")
|
||||||
|
|
||||||
application = get_wsgi_application()
|
application = get_wsgi_application()
|
||||||
Executable → Regular
+3
-2
@@ -1,12 +1,13 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
"""Django's command-line utility for administrative tasks."""
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Run administrative tasks."""
|
"""Run administrative tasks."""
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django")
|
||||||
try:
|
try:
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
@@ -18,5 +19,5 @@ def main():
|
|||||||
execute_from_command_line(sys.argv)
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[project]
|
||||||
|
name = "backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"coverage==7.6.10",
|
||||||
|
"django==5.1.7",
|
||||||
|
"django-cors-headers==4.6.0",
|
||||||
|
"django-filter==25.1",
|
||||||
|
"djangorestframework==3.15.2",
|
||||||
|
"djangorestframework-simplejwt==5.4.0",
|
||||||
|
"gunicorn==23.0.0",
|
||||||
|
"pillow>=11.0.0",
|
||||||
|
"psycopg2-binary==2.9.10",
|
||||||
|
"pydantic==2.10.5",
|
||||||
|
"pydantic-settings==2.7.1",
|
||||||
|
"pytest==8.3.4",
|
||||||
|
"pytest-cov==6.0.0",
|
||||||
|
"pytest-django==4.9.0",
|
||||||
|
"python-dotenv==1.0.1",
|
||||||
|
"httpx>=0.28.0",
|
||||||
|
"ebooklib>=0.18",
|
||||||
|
"beautifulsoup4>=4.12.0",
|
||||||
|
"pypdf>=5.0.0",
|
||||||
|
]
|
||||||
+7
-2
@@ -1,3 +1,8 @@
|
|||||||
[pytest]
|
[tool:pytest]
|
||||||
DJANGO_SETTINGS_MODULE = config.settings
|
DJANGO_SETTINGS_MODULE = config.django
|
||||||
python_files = tests.py test_*.py *_tests.py
|
python_files = tests.py test_*.py *_tests.py
|
||||||
|
testpaths = apps
|
||||||
|
|
||||||
|
[coverage:run]
|
||||||
|
source = apps
|
||||||
|
omit = */tests/*,*/migrations/*,*/admin.py,*/apps.py
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
django>=5.0,<7.0
|
Django==5.1.7
|
||||||
djangorestframework>=3.15.0
|
djangorestframework==3.15.2
|
||||||
django-cors-headers>=4.0.0
|
djangorestframework-simplejwt==5.4.0
|
||||||
pydantic>=2.0.0
|
django-filter==25.1
|
||||||
pydantic-settings>=2.0.0
|
django-cors-headers==4.6.0
|
||||||
pytest-django>=4.5.0
|
psycopg2-binary==2.9.10
|
||||||
|
pydantic==2.10.5
|
||||||
|
pydantic-settings==2.7.1
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
gunicorn==23.0.0
|
||||||
|
Pillow>=11.0.0
|
||||||
|
pytest==8.3.4
|
||||||
|
pytest-django==4.9.0
|
||||||
|
pytest-cov==6.0.0
|
||||||
|
coverage==7.6.10
|
||||||
Generated
+673
@@ -0,0 +1,673 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-types"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyio"
|
||||||
|
version = "4.13.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "asgiref"
|
||||||
|
version = "3.11.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "beautifulsoup4" },
|
||||||
|
{ name = "coverage" },
|
||||||
|
{ name = "django" },
|
||||||
|
{ name = "django-cors-headers" },
|
||||||
|
{ name = "django-filter" },
|
||||||
|
{ name = "djangorestframework" },
|
||||||
|
{ name = "djangorestframework-simplejwt" },
|
||||||
|
{ name = "ebooklib" },
|
||||||
|
{ name = "gunicorn" },
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "pillow" },
|
||||||
|
{ name = "psycopg2-binary" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "pypdf" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-cov" },
|
||||||
|
{ name = "pytest-django" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "beautifulsoup4", specifier = ">=4.12.0" },
|
||||||
|
{ name = "coverage", specifier = "==7.6.10" },
|
||||||
|
{ name = "django", specifier = "==5.1.7" },
|
||||||
|
{ name = "django-cors-headers", specifier = "==4.6.0" },
|
||||||
|
{ name = "django-filter", specifier = "==25.1" },
|
||||||
|
{ name = "djangorestframework", specifier = "==3.15.2" },
|
||||||
|
{ name = "djangorestframework-simplejwt", specifier = "==5.4.0" },
|
||||||
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
|
{ name = "gunicorn", specifier = "==23.0.0" },
|
||||||
|
{ name = "httpx", specifier = ">=0.28.0" },
|
||||||
|
{ name = "pillow", specifier = ">=11.0.0" },
|
||||||
|
{ name = "psycopg2-binary", specifier = "==2.9.10" },
|
||||||
|
{ name = "pydantic", specifier = "==2.10.5" },
|
||||||
|
{ name = "pydantic-settings", specifier = "==2.7.1" },
|
||||||
|
{ name = "pypdf", specifier = ">=5.0.0" },
|
||||||
|
{ name = "pytest", specifier = "==8.3.4" },
|
||||||
|
{ name = "pytest-cov", specifier = "==6.0.0" },
|
||||||
|
{ name = "pytest-django", specifier = "==4.9.0" },
|
||||||
|
{ name = "python-dotenv", specifier = "==1.0.1" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "beautifulsoup4"
|
||||||
|
version = "4.14.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "soupsieve" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.5.20"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coverage"
|
||||||
|
version = "7.6.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/84/ba/ac14d281f80aab516275012e8875991bb06203957aa1e19950139238d658/coverage-7.6.10.tar.gz", hash = "sha256:7fb105327c8f8f0682e29843e2ff96af9dcbe5bab8eeb4b398c6a33a16d80a23", size = 803868, upload-time = "2024-12-26T16:59:18.734Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/77/19d09ea06f92fdf0487499283b1b7af06bc422ea94534c8fe3a4cd023641/coverage-7.6.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27c6e64726b307782fa5cbe531e7647aee385a29b2107cd87ba7c0105a5d3853", size = 208281, upload-time = "2024-12-26T16:57:42.968Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/67/5479b9f2f99fcfb49c0d5cf61912a5255ef80b6e80a3cddba39c38146cf4/coverage-7.6.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c56e097019e72c373bae32d946ecf9858fda841e48d82df7e81c63ac25554078", size = 208514, upload-time = "2024-12-26T16:57:45.747Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/d1/febf59030ce1c83b7331c3546d7317e5120c5966471727aa7ac157729c4b/coverage-7.6.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7827a5bc7bdb197b9e066cdf650b2887597ad124dd99777332776f7b7c7d0d0", size = 241537, upload-time = "2024-12-26T16:57:48.647Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/7e/5ac4c90192130e7cf8b63153fe620c8bfd9068f89a6d9b5f26f1550f7a26/coverage-7.6.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204a8238afe787323a8b47d8be4df89772d5c1e4651b9ffa808552bdf20e1d50", size = 238572, upload-time = "2024-12-26T16:57:51.668Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/03/0334a79b26ecf59958f2fe9dd1f5ab3e2f88db876f5071933de39af09647/coverage-7.6.10-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67926f51821b8e9deb6426ff3164870976fe414d033ad90ea75e7ed0c2e5022", size = 240639, upload-time = "2024-12-26T16:57:53.175Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/45/8a707f23c202208d7b286d78ad6233f50dcf929319b664b6cc18a03c1aae/coverage-7.6.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e78b270eadb5702938c3dbe9367f878249b5ef9a2fcc5360ac7bff694310d17b", size = 240072, upload-time = "2024-12-26T16:57:56.087Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/02/603ce0ac2d02bc7b393279ef618940b4a0535b0868ee791140bda9ecfa40/coverage-7.6.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:714f942b9c15c3a7a5fe6876ce30af831c2ad4ce902410b7466b662358c852c0", size = 238386, upload-time = "2024-12-26T16:57:57.572Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/62/4e6887e9be060f5d18f1dd58c2838b2d9646faf353232dec4e2d4b1c8644/coverage-7.6.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:abb02e2f5a3187b2ac4cd46b8ced85a0858230b577ccb2c62c81482ca7d18852", size = 240054, upload-time = "2024-12-26T16:57:58.967Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/74/83ae4151c170d8bd071924f212add22a0e62a7fe2b149edf016aeecad17c/coverage-7.6.10-cp312-cp312-win32.whl", hash = "sha256:55b201b97286cf61f5e76063f9e2a1d8d2972fc2fcfd2c1272530172fd28c359", size = 210904, upload-time = "2024-12-26T16:58:00.688Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/54/de0893186a221478f5880283119fc40483bc460b27c4c71d1b8bba3474b9/coverage-7.6.10-cp312-cp312-win_amd64.whl", hash = "sha256:e4ae5ac5e0d1e4edfc9b4b57b4cbecd5bc266a6915c500f358817a8496739247", size = 211692, upload-time = "2024-12-26T16:58:02.35Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/6d/31883d78865529257bf847df5789e2ae80e99de8a460c3453dbfbe0db069/coverage-7.6.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05fca8ba6a87aabdd2d30d0b6c838b50510b56cdcfc604d40760dae7153b73d9", size = 208308, upload-time = "2024-12-26T16:58:04.487Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/22/3f2b129cc08de00c83b0ad6252e034320946abfc3e4235c009e57cfeee05/coverage-7.6.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9e80eba8801c386f72e0712a0453431259c45c3249f0009aff537a517b52942b", size = 208565, upload-time = "2024-12-26T16:58:06.774Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/0a/d89bc2d1cc61d3a8dfe9e9d75217b2be85f6c73ebf1b9e3c2f4e797f4531/coverage-7.6.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a372c89c939d57abe09e08c0578c1d212e7a678135d53aa16eec4430adc5e690", size = 241083, upload-time = "2024-12-26T16:58:10.27Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/81/6d64b88a00c7a7aaed3a657b8eaa0931f37a6395fcef61e53ff742b49c97/coverage-7.6.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec22b5e7fe7a0fa8509181c4aac1db48f3dd4d3a566131b313d1efc102892c18", size = 238235, upload-time = "2024-12-26T16:58:12.497Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/0b/7797d4193f5adb4b837207ed87fecf5fc38f7cc612b369a8e8e12d9fa114/coverage-7.6.10-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26bcf5c4df41cad1b19c84af71c22cbc9ea9a547fc973f1f2cc9a290002c8b3c", size = 240220, upload-time = "2024-12-26T16:58:15.619Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/4d/6f83ca1bddcf8e51bf8ff71572f39a1c73c34cf50e752a952c34f24d0a60/coverage-7.6.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e4630c26b6084c9b3cb53b15bd488f30ceb50b73c35c5ad7871b869cb7365fd", size = 239847, upload-time = "2024-12-26T16:58:17.126Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/9d/2470df6aa146aff4c65fee0f87f58d2164a67533c771c9cc12ffcdb865d5/coverage-7.6.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2396e8116db77789f819d2bc8a7e200232b7a282c66e0ae2d2cd84581a89757e", size = 237922, upload-time = "2024-12-26T16:58:20.198Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/dd/723fef5d901e6a89f2507094db66c091449c8ba03272861eaefa773ad95c/coverage-7.6.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79109c70cc0882e4d2d002fe69a24aa504dec0cc17169b3c7f41a1d341a73694", size = 239783, upload-time = "2024-12-26T16:58:23.614Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/f7/64d3298b2baf261cb35466000628706ce20a82d42faf9b771af447cd2b76/coverage-7.6.10-cp313-cp313-win32.whl", hash = "sha256:9e1747bab246d6ff2c4f28b4d186b205adced9f7bd9dc362051cc37c4a0c7bd6", size = 210965, upload-time = "2024-12-26T16:58:26.765Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/58/ec43499a7fc681212fe7742fe90b2bc361cdb72e3181ace1604247a5b24d/coverage-7.6.10-cp313-cp313-win_amd64.whl", hash = "sha256:254f1a3b1eef5f7ed23ef265eaa89c65c8c5b6b257327c149db1ca9d4a35f25e", size = 211719, upload-time = "2024-12-26T16:58:28.781Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/c9/f2857a135bcff4330c1e90e7d03446b036b2363d4ad37eb5e3a47bbac8a6/coverage-7.6.10-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2ccf240eb719789cedbb9fd1338055de2761088202a9a0b73032857e53f612fe", size = 209050, upload-time = "2024-12-26T16:58:31.616Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/b3/f840e5bd777d8433caa9e4a1eb20503495709f697341ac1a8ee6a3c906ad/coverage-7.6.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0c807ca74d5a5e64427c8805de15b9ca140bba13572d6d74e262f46f50b13273", size = 209321, upload-time = "2024-12-26T16:58:34.509Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/7d/125a5362180fcc1c03d91850fc020f3831d5cda09319522bcfa6b2b70be7/coverage-7.6.10-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bcfa46d7709b5a7ffe089075799b902020b62e7ee56ebaed2f4bdac04c508d8", size = 252039, upload-time = "2024-12-26T16:58:36.072Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/9c/4358bf3c74baf1f9bddd2baf3756b54c07f2cfd2535f0a47f1e7757e54b3/coverage-7.6.10-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e0de1e902669dccbf80b0415fb6b43d27edca2fbd48c74da378923b05316098", size = 247758, upload-time = "2024-12-26T16:58:39.458Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/c7/de3eb6fc5263b26fab5cda3de7a0f80e317597a4bad4781859f72885f300/coverage-7.6.10-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7b444c42bbc533aaae6b5a2166fd1a797cdb5eb58ee51a92bee1eb94a1e1cb", size = 250119, upload-time = "2024-12-26T16:58:41.018Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/e6/43de91f8ba2ec9140c6a4af1102141712949903dc732cf739167cfa7a3bc/coverage-7.6.10-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b330368cb99ef72fcd2dc3ed260adf67b31499584dc8a20225e85bfe6f6cfed0", size = 249597, upload-time = "2024-12-26T16:58:42.827Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/40/61158b5499aa2adf9e37bc6d0117e8f6788625b283d51e7e0c53cf340530/coverage-7.6.10-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9a7cfb50515f87f7ed30bc882f68812fd98bc2852957df69f3003d22a2aa0abf", size = 247473, upload-time = "2024-12-26T16:58:44.486Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/69/b3f2416725621e9f112e74e8470793d5b5995f146f596f133678a633b77e/coverage-7.6.10-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f93531882a5f68c28090f901b1d135de61b56331bba82028489bc51bdd818d2", size = 248737, upload-time = "2024-12-26T16:58:45.919Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/6e/fe899fb937657db6df31cc3e61c6968cb56d36d7326361847440a430152e/coverage-7.6.10-cp313-cp313t-win32.whl", hash = "sha256:89d76815a26197c858f53c7f6a656686ec392b25991f9e409bcef020cd532312", size = 211611, upload-time = "2024-12-26T16:58:47.883Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/55/52f5e66142a9d7bc93a15192eba7a78513d2abf6b3558d77b4ca32f5f424/coverage-7.6.10-cp313-cp313t-win_amd64.whl", hash = "sha256:54a5f0f43950a36312155dae55c505a76cd7f2b12d26abeebbe7a0b36dbc868d", size = 212781, upload-time = "2024-12-26T16:58:50.822Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django"
|
||||||
|
version = "5.1.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "asgiref" },
|
||||||
|
{ name = "sqlparse" },
|
||||||
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5f/57/11186e493ddc5a5e92cc7924a6363f7d4c2b645f7d7cb04a26a63f9bfb8b/Django-5.1.7.tar.gz", hash = "sha256:30de4ee43a98e5d3da36a9002f287ff400b43ca51791920bfb35f6917bfe041c", size = 10716510, upload-time = "2025-03-06T12:52:18.938Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/0f/7e042df3d462d39ae01b27a09ee76653692442bc3701fbfa6cb38e12889d/Django-5.1.7-py3-none-any.whl", hash = "sha256:1323617cb624add820cb9611cdcc788312d250824f92ca6048fda8625514af2b", size = 8276912, upload-time = "2025-03-06T12:52:12.784Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-cors-headers"
|
||||||
|
version = "4.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "asgiref" },
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c6/e5/3b67fc05b9c02b926411436dfc553829bc00843706ce7f99752433017f47/django_cors_headers-4.6.0.tar.gz", hash = "sha256:14d76b4b4c8d39375baeddd89e4f08899051eeaf177cb02a29bd6eae8cf63aa8", size = 20961, upload-time = "2024-10-29T10:38:15.281Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/73/689532cf164ab10ed1521d825ea156656520cec98886c8d2ac1ce8829220/django_cors_headers-4.6.0-py3-none-any.whl", hash = "sha256:8edbc0497e611c24d5150e0055d3b178c6534b8ed826fb6f53b21c63f5d48ba3", size = 12791, upload-time = "2024-10-29T10:38:13.784Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-filter"
|
||||||
|
version = "25.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b5/40/c702a6fe8cccac9bf426b55724ebdf57d10a132bae80a17691d0cf0b9bac/django_filter-25.1.tar.gz", hash = "sha256:1ec9eef48fa8da1c0ac9b411744b16c3f4c31176c867886e4c48da369c407153", size = 143021, upload-time = "2025-02-14T16:30:53.238Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/a6/70dcd68537c434ba7cb9277d403c5c829caf04f35baf5eb9458be251e382/django_filter-25.1-py3-none-any.whl", hash = "sha256:4fa48677cf5857b9b1347fed23e355ea792464e0fe07244d1fdfb8a806215b80", size = 94114, upload-time = "2025-02-14T16:30:50.435Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "djangorestframework"
|
||||||
|
version = "3.15.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2c/ce/31482eb688bdb4e271027076199e1aa8d02507e530b6d272ab8b4481557c/djangorestframework-3.15.2.tar.gz", hash = "sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad", size = 1067420, upload-time = "2024-06-19T07:59:32.891Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/b6/fa99d8f05eff3a9310286ae84c4059b08c301ae4ab33ae32e46e8ef76491/djangorestframework-3.15.2-py3-none-any.whl", hash = "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20", size = 1071235, upload-time = "2024-06-19T07:59:26.106Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "djangorestframework-simplejwt"
|
||||||
|
version = "5.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
{ name = "djangorestframework" },
|
||||||
|
{ name = "pyjwt" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8d/5f/1c130e823f734ba776c3925ad8e7c28ca1a59feb22d039b3810d1f8c0b34/djangorestframework_simplejwt-5.4.0.tar.gz", hash = "sha256:cccecce1a0e1a4a240fae80da73e5fc23055bababb8b67de88fa47cd36822320", size = 96648, upload-time = "2025-01-07T08:25:11.667Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/41/d6f67d24f46c7d8ee6dcb85c9abb94bad23140210bfb8c454641d278dfd2/djangorestframework_simplejwt-5.4.0-py3-none-any.whl", hash = "sha256:7aec953db9ed4163430c16d086eecb0f028f814ce6bba62b06c25919261e9077", size = 102316, upload-time = "2025-01-07T08:24:30.503Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ebooklib"
|
||||||
|
version = "0.20"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "lxml" },
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/77/85/322e8882a582d4b707220d1929cfb74c125f2ba513991edbce40dbc462de/ebooklib-0.20.tar.gz", hash = "sha256:35e2f9d7d39907be8d39ae2deb261b19848945903ae3dbb6577b187ead69e985", size = 127066, upload-time = "2025-10-26T20:56:20.968Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/ee/aa015c5de8b0dc42a8e507eae8c2de5d1c0e068c896858fec6d502402ed6/ebooklib-0.20-py3-none-any.whl", hash = "sha256:fff5322517a37e31c972d27be7d982cc3928c16b3dcc5fd7e8f7c0f5d7bcf42b", size = 40995, upload-time = "2025-10-26T20:56:19.104Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gunicorn"
|
||||||
|
version = "23.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "packaging" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h11"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.18"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lxml"
|
||||||
|
version = "6.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pillow"
|
||||||
|
version = "12.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psycopg2-binary"
|
||||||
|
version = "2.9.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", size = 385764, upload-time = "2024-10-16T11:24:58.126Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/7d/465cc9795cf76f6d329efdafca74693714556ea3891813701ac1fee87545/psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0", size = 3044771, upload-time = "2024-10-16T11:20:35.234Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/31/6d225b7b641a1a2148e3ed65e1aa74fc86ba3fee850545e27be9e1de893d/psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a", size = 3275336, upload-time = "2024-10-16T11:20:38.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/b7/a68c2b4bff1cbb1728e3ec864b2d92327c77ad52edcd27922535a8366f68/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539", size = 2851637, upload-time = "2024-10-16T11:20:42.145Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/b1/cfedc0e0e6f9ad61f8657fd173b2f831ce261c02a08c0b09c652b127d813/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526", size = 3082097, upload-time = "2024-10-16T11:20:46.185Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/ed/0a8e4153c9b769f59c02fb5e7914f20f0b2483a19dae7bf2db54b743d0d0/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1", size = 3264776, upload-time = "2024-10-16T11:20:50.879Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/db/d09da68c6a0cdab41566b74e0a6068a425f077169bed0946559b7348ebe9/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e", size = 3020968, upload-time = "2024-10-16T11:20:56.819Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/28/4d6f8c255f0dfffb410db2b3f9ac5218d959a66c715c34cac31081e19b95/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f", size = 2872334, upload-time = "2024-10-16T11:21:02.411Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/f7/20d7bf796593c4fea95e12119d6cc384ff1f6141a24fbb7df5a668d29d29/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00", size = 2822722, upload-time = "2024-10-16T11:21:09.01Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/e4/0c407ae919ef626dbdb32835a03b6737013c3cc7240169843965cada2bdf/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5", size = 2920132, upload-time = "2024-10-16T11:21:16.339Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/70/aa69c9f69cf09a01da224909ff6ce8b68faeef476f00f7ec377e8f03be70/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47", size = 2959312, upload-time = "2024-10-16T11:21:25.584Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/bd/213e59854fafe87ba47814bf413ace0dcee33a89c8c8c814faca6bc7cf3c/psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64", size = 1025191, upload-time = "2024-10-16T11:21:29.912Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/29/06261ea000e2dc1e22907dbbc483a1093665509ea586b29b8986a0e56733/psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0", size = 1164031, upload-time = "2024-10-16T11:21:34.211Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/30/d41d3ba765609c0763505d565c4d12d8f3c79793f0d0f044ff5a28bf395b/psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", size = 3044699, upload-time = "2024-10-16T11:21:42.841Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/44/257ddadec7ef04536ba71af6bc6a75ec05c5343004a7ec93006bee66c0bc/psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", size = 3275245, upload-time = "2024-10-16T11:21:51.989Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/11/48ea1cd11de67f9efd7262085588790a95d9dfcd9b8a687d46caf7305c1a/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", size = 2851631, upload-time = "2024-10-16T11:21:57.584Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/e0/62ce5ee650e6c86719d621a761fe4bc846ab9eff8c1f12b1ed5741bf1c9b/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", size = 3082140, upload-time = "2024-10-16T11:22:02.005Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/ce/63f946c098611f7be234c0dd7cb1ad68b0b5744d34f68062bb3c5aa510c8/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", size = 3264762, upload-time = "2024-10-16T11:22:06.412Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/25/c603cd81402e69edf7daa59b1602bd41eb9859e2824b8c0855d748366ac9/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", size = 3020967, upload-time = "2024-10-16T11:22:11.583Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/d6/8708d8c6fca531057fa170cdde8df870e8b6a9b136e82b361c65e42b841e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", size = 2872326, upload-time = "2024-10-16T11:22:16.406Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", size = 2822712, upload-time = "2024-10-16T11:22:21.366Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", size = 2920155, upload-time = "2024-10-16T11:22:25.684Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", size = 2959356, upload-time = "2024-10-16T11:22:30.562Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224, upload-time = "2025-01-04T20:09:19.234Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic"
|
||||||
|
version = "2.10.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-types" },
|
||||||
|
{ name = "pydantic-core" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6a/c7/ca334c2ef6f2e046b1144fe4bb2a5da8a4c574e7f2ebf7e16b34a6a2fa92/pydantic-2.10.5.tar.gz", hash = "sha256:278b38dbbaec562011d659ee05f63346951b3a248a6f3642e1bc68894ea2b4ff", size = 761287, upload-time = "2025-01-09T13:33:25.929Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/26/82663c79010b28eddf29dcdd0ea723439535fa917fce5905885c0e9ba562/pydantic-2.10.5-py3-none-any.whl", hash = "sha256:4dd4e322dbe55472cb7ca7e73f4b63574eecccf2835ffa2af9021ce113c83c53", size = 431426, upload-time = "2025-01-09T13:33:22.312Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-core"
|
||||||
|
version = "2.27.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443, upload-time = "2024-12-18T11:31:54.917Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127, upload-time = "2024-12-18T11:28:30.346Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340, upload-time = "2024-12-18T11:28:32.521Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900, upload-time = "2024-12-18T11:28:34.507Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177, upload-time = "2024-12-18T11:28:36.488Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046, upload-time = "2024-12-18T11:28:39.409Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386, upload-time = "2024-12-18T11:28:41.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060, upload-time = "2024-12-18T11:28:44.709Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870, upload-time = "2024-12-18T11:28:46.839Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822, upload-time = "2024-12-18T11:28:48.896Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364, upload-time = "2024-12-18T11:28:50.755Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303, upload-time = "2024-12-18T11:28:54.122Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064, upload-time = "2024-12-18T11:28:56.074Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046, upload-time = "2024-12-18T11:28:58.107Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092, upload-time = "2024-12-18T11:29:01.335Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709, upload-time = "2024-12-18T11:29:03.193Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273, upload-time = "2024-12-18T11:29:05.306Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027, upload-time = "2024-12-18T11:29:07.294Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888, upload-time = "2024-12-18T11:29:09.249Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738, upload-time = "2024-12-18T11:29:11.23Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138, upload-time = "2024-12-18T11:29:16.396Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025, upload-time = "2024-12-18T11:29:20.25Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633, upload-time = "2024-12-18T11:29:23.877Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404, upload-time = "2024-12-18T11:29:25.872Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130, upload-time = "2024-12-18T11:29:29.252Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946, upload-time = "2024-12-18T11:29:31.338Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387, upload-time = "2024-12-18T11:29:33.481Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453, upload-time = "2024-12-18T11:29:35.533Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186, upload-time = "2024-12-18T11:29:37.649Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-settings"
|
||||||
|
version = "2.7.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/73/7b/c58a586cd7d9ac66d2ee4ba60ca2d241fa837c02bca9bea80a9a8c3d22a9/pydantic_settings-2.7.1.tar.gz", hash = "sha256:10c9caad35e64bfb3c2fbf70a078c0e25cc92499782e5200747f942a065dec93", size = 79920, upload-time = "2024-12-31T11:27:44.632Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/46/93416fdae86d40879714f72956ac14df9c7b76f7d41a4d68aa9f71a0028b/pydantic_settings-2.7.1-py3-none-any.whl", hash = "sha256:590be9e6e24d06db33a4262829edef682500ef008565a969c73d39d5f8bfb3fd", size = 29718, upload-time = "2024-12-31T11:27:43.201Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyjwt"
|
||||||
|
version = "2.13.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pypdf"
|
||||||
|
version = "6.12.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0a/6d/20879428577c1e57ecd41b69dc86beabf43db9287ad2e702207f8b48c751/pypdf-6.12.2.tar.gz", hash = "sha256:111669eb6680c04495ae0c113a1476e3bf93a95761d23c7406b591c80a6490b1", size = 6468184, upload-time = "2026-05-26T13:31:26.911Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/44/fee070a16639d9869bb6a7e0f3a1b3946da1d66f32b9260b4d19cb90d7b2/pypdf-6.12.2-py3-none-any.whl", hash = "sha256:67b2699357a1f3f4c945940ea80826349ee507c9e2577724a14b4941982c104d", size = 343865, upload-time = "2026-05-26T13:31:25.068Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "8.3.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919, upload-time = "2024-12-01T12:54:25.98Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083, upload-time = "2024-12-01T12:54:19.735Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-cov"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "coverage" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945, upload-time = "2024-10-29T20:13:35.363Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949, upload-time = "2024-10-29T20:13:33.215Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-django"
|
||||||
|
version = "4.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/02/c0/43c8b2528c24d7f1a48a47e3f7381f5ab2ae8c64634b0c3f4bd843063955/pytest_django-4.9.0.tar.gz", hash = "sha256:8bf7bc358c9ae6f6fc51b6cebb190fe20212196e6807121f11bd6a3b03428314", size = 84067, upload-time = "2024-09-02T15:49:18.407Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/fe/54f387ee1b41c9ad59e48fb8368a361fad0600fe404315e31a12bacaea7d/pytest_django-4.9.0-py3-none-any.whl", hash = "sha256:1d83692cb39188682dbb419ff0393867e9904094a549a7d38a3154d5731b2b99", size = 23723, upload-time = "2024-09-02T15:49:17.127Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dotenv"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "six"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "soupsieve"
|
||||||
|
version = "2.8.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlparse"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tzdata"
|
||||||
|
version = "2026.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
|
||||||
|
]
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:15
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-cloud_reader}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d cloud_reader"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
|
||||||
|
DJANGO_DEBUG: ${DJANGO_DEBUG:-True}
|
||||||
|
DB_NAME: ${POSTGRES_DB:-cloud_reader}
|
||||||
|
DB_USER: ${POSTGRES_USER:-postgres}
|
||||||
|
DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
|
DB_HOST: ${DB_HOST:-db}
|
||||||
|
DB_PORT: ${DB_PORT:-5432}
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- ./backend/media:/app/media
|
||||||
|
command: >
|
||||||
|
sh -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
environment:
|
||||||
|
VITE_API_URL: ${VITE_API_URL:-http://localhost:8000}
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
- /app/node_modules
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# 010 — Open Library metadata on import
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Created:** 2026-06-03
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
After a user uploads an EPUB/PDF (`POST /api/books/ebooks/`), enrich the `EBook` with metadata and a cover from [Open Library](https://openlibrary.org/developers/api), using only **title** and **author** from the upload form. Prefer **Spanish** editions when available; fall back to English/any.
|
||||||
|
|
||||||
|
Upload must **never fail** if Open Library is down or no match is found.
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
- **Automatic:** `EBookUploadSerializer.create()` calls `enrich_ebook_metadata(ebook)` after file save.
|
||||||
|
- **Manual:** `POST /api/books/ebooks/{id}/enrich-metadata/` re-runs enrichment (owner only).
|
||||||
|
|
||||||
|
## Open Library usage
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
`GET https://openlibrary.org/search.json`
|
||||||
|
|
||||||
|
| Param | Value |
|
||||||
|
|-------|--------|
|
||||||
|
| `title` | User-provided title |
|
||||||
|
| `author` | User-provided author |
|
||||||
|
| `lang` | `es` (primary) or `en` (fallback) |
|
||||||
|
| `limit` | `5` |
|
||||||
|
| `fields` | `key,title,author_name,cover_i,first_publish_year,subject,language,edition_key,number_of_pages_median,publisher` |
|
||||||
|
|
||||||
|
Primary pass also uses query filter `language:spa`. Fallback omits language filter.
|
||||||
|
|
||||||
|
### Covers
|
||||||
|
|
||||||
|
`GET https://covers.openlibrary.org/b/id/{cover_i}-L.jpg` — downloaded and stored on `EBook.cover_image`.
|
||||||
|
|
||||||
|
## Match scoring
|
||||||
|
|
||||||
|
| Score | Behavior |
|
||||||
|
|-------|----------|
|
||||||
|
| ≥ 0.8 | Apply OL title/author + cover + full metadata |
|
||||||
|
| 0.6 – 0.8 | Metadata + cover only; keep user title/author |
|
||||||
|
| < 0.6 | `match_status: not_found`; no field changes except metadata stub |
|
||||||
|
|
||||||
|
Author overlap + title similarity (normalized strings, `difflib.SequenceMatcher`). Prefer hits with `cover_i`.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
No migration. Uses existing fields on `EBook`:
|
||||||
|
|
||||||
|
- `metadata_json` — full enrichment payload (see below)
|
||||||
|
- `cover_image` — downloaded cover file
|
||||||
|
- `title` / `author` — updated when match score ≥ 0.8
|
||||||
|
|
||||||
|
### `metadata_json` shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"source": "openlibrary",
|
||||||
|
"matched_at": "2026-06-03T12:00:00+00:00",
|
||||||
|
"match_language": "es",
|
||||||
|
"match_score": 0.92,
|
||||||
|
"match_status": "matched",
|
||||||
|
"user_input": { "title": "...", "author": "..." },
|
||||||
|
"openlibrary": {
|
||||||
|
"work_key": "/works/OL...",
|
||||||
|
"edition_key": "...",
|
||||||
|
"title": "...",
|
||||||
|
"authors": ["..."],
|
||||||
|
"cover_id": 12345,
|
||||||
|
"cover_url": "https://covers.openlibrary.org/b/id/12345-L.jpg",
|
||||||
|
"first_publish_year": 1605,
|
||||||
|
"subjects": ["..."],
|
||||||
|
"languages": ["spa"],
|
||||||
|
"publishers": ["..."],
|
||||||
|
"number_of_pages_median": 320
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API changes
|
||||||
|
|
||||||
|
### Upload response (unchanged path)
|
||||||
|
|
||||||
|
`POST /api/books/ebooks/` — response may include populated `cover_image` and updated `title`/`author` after sync enrichment.
|
||||||
|
|
||||||
|
### Detail
|
||||||
|
|
||||||
|
`GET /api/books/ebooks/{id}/` — adds read-only `metadata` (alias of `metadata_json`).
|
||||||
|
|
||||||
|
### Manual refresh
|
||||||
|
|
||||||
|
`POST /api/books/ebooks/{id}/enrich-metadata/` — returns updated `EBookDetailSerializer` payload.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Env var | Default | Description |
|
||||||
|
|---------|---------|-------------|
|
||||||
|
| `OPENLIBRARY_ENABLED` | `true` | Kill switch |
|
||||||
|
| `OPENLIBRARY_PREFERRED_LANG` | `es` | Primary `lang` param |
|
||||||
|
| `OPENLIBRARY_FALLBACK_LANG` | `en` | Fallback `lang` param |
|
||||||
|
| `OPENLIBRARY_TIMEOUT_SECONDS` | `5` | HTTP timeout |
|
||||||
|
| `OPENLIBRARY_USER_AGENT` | `CloudReader/1.0` | User-Agent header |
|
||||||
|
|
||||||
|
## Code layout
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/apps/books/services/
|
||||||
|
├── openlibrary.py # Search, scoring, cover download
|
||||||
|
└── metadata.py # enrich_ebook_metadata orchestrator
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Upload with title `Don Quijote`, author `Cervantes` → cover + Spanish-friendly metadata.
|
||||||
|
2. Upload with nonsense title/author → 201, no cover, `match_status: not_found`.
|
||||||
|
3. `POST .../enrich-metadata/` on existing ebook refreshes metadata.
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# Backend API Specification — Cloud Reader
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The Cloud Reader backend provides a RESTful API for managing a user's personal book library. Built with Django 5 + Django REST Framework.
|
|
||||||
|
|
||||||
## Models
|
|
||||||
|
|
||||||
### Book
|
|
||||||
|
|
||||||
| Field | Type | Constraints |
|
|
||||||
|------------------|--------------------|---------------------------------|
|
|
||||||
| `title` | `CharField(500)` | Required |
|
|
||||||
| `author` | `CharField(500)` | Required |
|
|
||||||
| `genre` | `CharField(200)` | Optional, blank allowed |
|
|
||||||
| `description` | `TextField` | Optional, blank allowed |
|
|
||||||
| `cover_image_url`| `URLField` | Optional, blank allowed |
|
|
||||||
| `isbn` | `CharField(20)` | Optional, blank allowed |
|
|
||||||
| `total_pages` | `PositiveIntegerField` | Default 0 |
|
|
||||||
| `current_page` | `PositiveIntegerField` | Default 0, validated ≤ total |
|
|
||||||
| `reading_status` | `CharField(20)` | Choices: `not_started`, `reading`, `finished`, `dnf` |
|
|
||||||
| `owner` | `ForeignKey(User)` | Set automatically on create |
|
|
||||||
| `created_at` | `DateTimeField` | Auto-set on create |
|
|
||||||
| `updated_at` | `DateTimeField` | Auto-set on update |
|
|
||||||
|
|
||||||
**Properties:**
|
|
||||||
- `reading_progress` — computed `(current_page / total_pages) * 100`, returns `0.0` when `total_pages` is 0.
|
|
||||||
|
|
||||||
**Indexes:** Composite indexes on `(owner, reading_status)`, `(owner, title)`, `(owner, author)`.
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
Base URL: `/api/`
|
|
||||||
|
|
||||||
Authentication: SessionAuthentication + BasicAuthentication (DRF defaults).
|
|
||||||
|
|
||||||
Permissions: All book endpoints require `IsAuthenticated`. Users can only access their own books.
|
|
||||||
|
|
||||||
### Books
|
|
||||||
|
|
||||||
| Method | URL | Action | Serializer |
|
|
||||||
|----------|------------------------------------|---------------|--------------------|
|
|
||||||
| `GET` | `/api/books/` | List books | `BookListSerializer` |
|
|
||||||
| `POST` | `/api/books/` | Create book | `BookDetailSerializer` |
|
|
||||||
| `GET` | `/api/books/{id}/` | Retrieve book | `BookDetailSerializer` |
|
|
||||||
| `PUT` | `/api/books/{id}/` | Full update | `BookDetailSerializer` |
|
|
||||||
| `PATCH` | `/api/books/{id}/` | Partial update| `BookDetailSerializer` |
|
|
||||||
| `DELETE` | `/api/books/{id}/` | Delete book | — |
|
|
||||||
| `POST` | `/api/books/{id}/mark_finished/` | Mark finished | `BookDetailSerializer` |
|
|
||||||
| `GET` | `/api/books/stats/` | Library stats | — (custom) |
|
|
||||||
|
|
||||||
### Query Parameters (List)
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
|------------------|----------|--------------------------------------------------|
|
|
||||||
| `page` | int | Page number for pagination (20 items/page) |
|
|
||||||
| `sort_by` | string | `title`, `-title`, `author`, `-author`, `-created_at`, `-updated_at`, `-reading_progress`, `reading_progress` |
|
|
||||||
| `reading_status` | string | Filter by status value |
|
|
||||||
| `search` | string | Search in title and author fields (icontains) |
|
|
||||||
|
|
||||||
### Stats Response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"total_books": 10,
|
|
||||||
"finished": 3,
|
|
||||||
"reading": 4,
|
|
||||||
"not_started": 3
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mark Finished
|
|
||||||
|
|
||||||
`POST /api/books/{id}/mark_finished/` sets `reading_status` to `finished` and `current_page` to `total_pages`.
|
|
||||||
|
|
||||||
## Validation Rules
|
|
||||||
|
|
||||||
- Title and author cannot be empty or whitespace-only
|
|
||||||
- `current_page` cannot exceed `total_pages` (when `total_pages > 0`)
|
|
||||||
- Setting `reading_status` to `finished` automatically sets `current_page = total_pages`
|
|
||||||
|
|
||||||
## Admin
|
|
||||||
|
|
||||||
Books are registered in Django admin with list display, filters by `reading_status` and `genre`, and search by `title`/`author`.
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Book Search & Discovery — Spec
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Enable users to search books within the library and discover new books via filters and a dedicated detail view.
|
||||||
|
|
||||||
|
## Backend API Contracts
|
||||||
|
|
||||||
|
### Book List & Search
|
||||||
|
`GET /api/books/`
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
| Param | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `q` | string | Full-text search across title, author, genre |
|
||||||
|
| `genre` | string | Exact filter by genre |
|
||||||
|
| `author` | string | Exact filter by author |
|
||||||
|
| `reading_status` | string | Filter: `want_to_read`, `reading`, `finished`, `dnf` |
|
||||||
|
| `ordering` | string | `title`, `author`, `genre`, `created_at` (prefix `-` for desc) |
|
||||||
|
| `page` | int | Page number (default: 1) |
|
||||||
|
|
||||||
|
**Response (paginated):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"count": 42,
|
||||||
|
"next": "http://.../?page=2",
|
||||||
|
"previous": null,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Dune",
|
||||||
|
"author": "Frank Herbert",
|
||||||
|
"genre": "Science Fiction",
|
||||||
|
"reading_status": "finished",
|
||||||
|
"reading_status_display": "Finished",
|
||||||
|
"cover_image": "https://..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Book Detail
|
||||||
|
`GET /api/books/{id}/`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Dune",
|
||||||
|
"author": "Frank Herbert",
|
||||||
|
"genre": "Science Fiction",
|
||||||
|
"description": "...",
|
||||||
|
"reading_status": "finished",
|
||||||
|
"reading_status_display": "Finished",
|
||||||
|
"cover_image": "https://...",
|
||||||
|
"total_pages": 688,
|
||||||
|
"created_at": "2025-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2025-01-15T00:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Genre / Author Discovery
|
||||||
|
`GET /api/books/genres/` → `["Fiction", "Science Fiction", ...]`
|
||||||
|
|
||||||
|
`GET /api/books/authors/` → `["Frank Herbert", "Ursula K. Le Guin", ...]`
|
||||||
|
|
||||||
|
## Frontend Components
|
||||||
|
|
||||||
|
### LibraryPage (enhanced)
|
||||||
|
- **Search bar** at top: text input with debounced `onChange` → calls API with `q` param
|
||||||
|
- **Filter row**: genre dropdown, author dropdown, reading status dropdown
|
||||||
|
- Genre/Author dropdowns populated from `/api/books/genres/` and `/api/books/authors/`
|
||||||
|
- Reading status uses static enum values (`READING_STATUS_OPTIONS`)
|
||||||
|
- **Results grid**: card layout showing cover, title, author, reading status badge
|
||||||
|
- **Empty state**: "No books found" with clear message when results are empty
|
||||||
|
- **Loading state**: spinner/skeleton while fetching
|
||||||
|
- **Click card → navigate to** `/books/{id}`
|
||||||
|
|
||||||
|
### BookDetailPage (new)
|
||||||
|
- Shows full book info: cover, title, author, genre, description, reading status, total pages
|
||||||
|
- Back button to return to library
|
||||||
|
- Clean, mobile-responsive layout
|
||||||
|
|
||||||
|
### API Client — `frontend/src/api/books.ts`
|
||||||
|
|
||||||
|
| Method | Endpoint | Returns |
|
||||||
|
|--------|----------|---------|
|
||||||
|
| `searchBooks(params)` | `GET /api/books/` | `{count, results: BookListItem[]}` |
|
||||||
|
| `getBook(id)` | `GET /api/books/{id}/` | `BookDetail` |
|
||||||
|
| `getGenres()` | `GET /api/books/genres/` | `string[]` |
|
||||||
|
| `getAuthors()` | `GET /api/books/authors/` | `string[]` |
|
||||||
|
|
||||||
|
## Routes (Frontend)
|
||||||
|
| Path | Component | Auth |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `/` | LibraryPage | Protected |
|
||||||
|
| `/books/:id` | BookDetailPage | Protected |
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
1. User types in search bar → 300ms debounce → `GET /api/books/?q=...`
|
||||||
|
2. User selects filter → `GET /api/books/?genre=...&author=...&reading_status=...`
|
||||||
|
3. User clicks result → navigate to `/books/:id`
|
||||||
|
4. BookDetailPage → `GET /api/books/{id}/`
|
||||||
|
|
||||||
|
## Mobile Optimizations
|
||||||
|
- Filters collapse into a toggleable panel on small screens
|
||||||
|
- Cards stack in single column on mobile
|
||||||
|
- Touch-friendly tap targets (min 44px)
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
# US: Customizable Mobile Reading Experience
|
||||||
|
|
||||||
|
**Issue:** https://gitea-dev.codescripters.org/HermesFactory/cloud-reader/issues (TBD)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add a full-screen reading view for ebooks with customizable typography, themes,
|
||||||
|
table of contents navigation, and orientation support. Mobile-first, responsive
|
||||||
|
design that adapts to any screen size.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Specification
|
||||||
|
|
||||||
|
### New Models
|
||||||
|
|
||||||
|
#### `apps.books.models.Chapter`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------------|--------------------|--------------------------------|
|
||||||
|
| id | AutoField (PK) | |
|
||||||
|
| book | FK -> Book | related_name="chapters" |
|
||||||
|
| title | CharField(512) | Chapter title |
|
||||||
|
| number | PositiveIntegerField | Chapter ordering / TOC index |
|
||||||
|
| content | TextField | Chapter text/markdown content |
|
||||||
|
| created_at | DateTimeField | auto_now_add |
|
||||||
|
| updated_at | DateTimeField | auto_now |
|
||||||
|
|
||||||
|
**Constraints:** UniqueConstraint(book, chapter_number)
|
||||||
|
**Ordering:** [book, number]
|
||||||
|
**Index:** FK to book with db_index
|
||||||
|
|
||||||
|
#### `apps.books.models.ReadingProgress`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|------------------|--------------------|--------------------------------|
|
||||||
|
| id | AutoField (PK) | |
|
||||||
|
| user | FK -> User | related_name="reading_progress"|
|
||||||
|
| book | FK -> Book | related_name="reading_progress"|
|
||||||
|
| current_chapter | PositiveIntegerField | Last chapter number |
|
||||||
|
| current_position | PositiveIntegerField | Position within chapter (paragraph) |
|
||||||
|
| percentage | FloatField | 0.0 - 100.0 overall progress |
|
||||||
|
| updated_at | DateTimeField | auto_now |
|
||||||
|
|
||||||
|
**Constraints:** UniqueConstraint(user, book)
|
||||||
|
**Indexes:** (user, book) composite, (user) filter for list queries
|
||||||
|
|
||||||
|
#### `apps.reader.models.ReadingSettings`
|
||||||
|
|
||||||
|
New app `apps/reader/` for reading preferences, isolated from book data model.
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------------------|--------------------|-------------------------------|
|
||||||
|
| id | AutoField (PK) | |
|
||||||
|
| user | OneToOneField -> User | related_name="reading_settings" |
|
||||||
|
| font_family | CharField(32) | "sans-serif", "serif", "monospace" |
|
||||||
|
| font_size | PositiveSmallIntegerField | 12-32, default 18 |
|
||||||
|
| line_height | FloatField | 1.2 - 2.0, default 1.6 |
|
||||||
|
| margin_width | PositiveSmallIntegerField | 8-48, default 16 (px) |
|
||||||
|
| background_color | CharField(7) | Hex color, default "#f5f0eb" |
|
||||||
|
| text_color | CharField(7) | Hex color, default "#1a1a1a" |
|
||||||
|
| brightness | PositiveSmallIntegerField | 0-100, default 100 |
|
||||||
|
| orientation_lock | CharField(16) | "auto", "portrait", "landscape" |
|
||||||
|
| theme | CharField(32) | "sepia", "dark", "light", "paper" |
|
||||||
|
| created_at | DateTimeField | auto_now_add |
|
||||||
|
| updated_at | DateTimeField | auto_now |
|
||||||
|
|
||||||
|
### New API Endpoints
|
||||||
|
|
||||||
|
All under `/api/` prefix, authenticated with JWT.
|
||||||
|
|
||||||
|
#### Reader Settings (`/api/reader/settings/`)
|
||||||
|
|
||||||
|
| Method | URL | Action |
|
||||||
|
|--------|------------------------------|---------------------------|
|
||||||
|
| GET | /api/reader/settings/ | Get current user settings |
|
||||||
|
| PUT | /api/reader/settings/ | Create/update settings |
|
||||||
|
| PATCH | /api/reader/settings/ | Partial update settings |
|
||||||
|
|
||||||
|
- Single-object endpoint (one settings record per user, auto-created on first GET)
|
||||||
|
- Validation: font_size 12-32, line_height 1.2-2.0, margin_width 8-48
|
||||||
|
|
||||||
|
#### Reading Progress (`/api/books/{id}/progress/`)
|
||||||
|
|
||||||
|
| Method | URL | Action |
|
||||||
|
|--------|----------------------------------------|------------------------------|
|
||||||
|
| GET | /api/books/{id}/progress/ | Get reading progress for book|
|
||||||
|
| PUT | /api/books/{id}/progress/ | Create/update reading progress|
|
||||||
|
|
||||||
|
- Nested under book detail
|
||||||
|
- Auto-creates progress record on first PUT
|
||||||
|
|
||||||
|
#### Chapters (`/api/books/{id}/chapters/`)
|
||||||
|
|
||||||
|
| Method | URL | Action |
|
||||||
|
|--------|----------------------------------------|------------------------------|
|
||||||
|
| GET | /api/books/{id}/chapters/ | List chapters for book (TOC) |
|
||||||
|
| GET | /api/books/{id}/chapters/{number}/ | Get specific chapter content |
|
||||||
|
|
||||||
|
- Ordering by `number`
|
||||||
|
- Used by frontend TOC sidebar and content loading
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Specification
|
||||||
|
|
||||||
|
### New Pages
|
||||||
|
|
||||||
|
#### `/reader/:bookId` — ReadingPage
|
||||||
|
|
||||||
|
Full-screen reading view with:
|
||||||
|
- Chapter content display (left/right swiping or scroll)
|
||||||
|
- Bottom toolbar: TOC toggle, Settings toggle, Progress indicator
|
||||||
|
- Top bar: Back button, Book title, Chapter title
|
||||||
|
- Swipe/tap/page navigation between chapters
|
||||||
|
|
||||||
|
### New Components
|
||||||
|
|
||||||
|
#### `ReaderToolbar`
|
||||||
|
- Fixed bottom toolbar
|
||||||
|
- TOC button (opens TOC drawer)
|
||||||
|
- Settings/theme button (opens settings panel)
|
||||||
|
- Progress bar showing overall reading progress
|
||||||
|
|
||||||
|
#### `TableOfContents`
|
||||||
|
- Slide-in drawer from left
|
||||||
|
- Lists all chapters with current chapter highlighted
|
||||||
|
- Tap on chapter to navigate
|
||||||
|
- Shows reading progress per chapter
|
||||||
|
|
||||||
|
#### `ReadingSettingsPanel`
|
||||||
|
- Slide-in drawer from right (or bottom sheet on mobile)
|
||||||
|
- Controls:
|
||||||
|
- Theme presets: Sepia, Dark, Light, Paper
|
||||||
|
- Font family: Sans-serif, Serif, Monospace
|
||||||
|
- Font size slider (12-32)
|
||||||
|
- Line height slider (1.2-2.0)
|
||||||
|
- Margin/padding control
|
||||||
|
- Orientation lock toggle (Auto / Portrait / Landscape)
|
||||||
|
- All changes persist immediately via API
|
||||||
|
- LocalStorage fallback when offline
|
||||||
|
|
||||||
|
### New Hooks
|
||||||
|
|
||||||
|
#### `useReadingSettings(bookId)`
|
||||||
|
- Fetches user reading settings from API
|
||||||
|
- Returns current settings + update function
|
||||||
|
- Applies CSS custom properties to document root
|
||||||
|
- Falls back to defaults if API unavailable
|
||||||
|
|
||||||
|
#### `useChapters(bookId)`
|
||||||
|
- Fetches chapter list for TOC
|
||||||
|
- Returns chapters array, current chapter, navigate function
|
||||||
|
- Prefetches next/prev chapter content
|
||||||
|
|
||||||
|
#### `useReadingProgress(bookId)`
|
||||||
|
- Fetches/updates reading progress
|
||||||
|
- Auto-saves position on chapter change and periodic interval
|
||||||
|
|
||||||
|
### New Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Chapter {
|
||||||
|
id: number;
|
||||||
|
book: number;
|
||||||
|
title: string;
|
||||||
|
number: number;
|
||||||
|
content?: string; // Only present when fetching individual chapter
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChapterSummary {
|
||||||
|
id: number;
|
||||||
|
book: number;
|
||||||
|
title: string;
|
||||||
|
number: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReadingSettings {
|
||||||
|
font_family: "sans-serif" | "serif" | "monospace";
|
||||||
|
font_size: number;
|
||||||
|
line_height: number;
|
||||||
|
margin_width: number;
|
||||||
|
background_color: string;
|
||||||
|
text_color: string;
|
||||||
|
brightness: number;
|
||||||
|
orientation_lock: "auto" | "portrait" | "landscape";
|
||||||
|
theme: "sepia" | "dark" | "light" | "paper";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReadingProgress {
|
||||||
|
current_chapter: number;
|
||||||
|
current_position: number;
|
||||||
|
percentage: number;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS / Theming
|
||||||
|
|
||||||
|
Reading view uses CSS custom properties driven by reading settings:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--reader-bg: var(--bg-color, #f5f0eb);
|
||||||
|
--reader-text: var(--text-color, #1a1a1a);
|
||||||
|
--reader-font-family: var(--font-family, "Georgia", serif);
|
||||||
|
--reader-font-size: var(--font-size, 18px);
|
||||||
|
--reader-line-height: var(--line-height, 1.6);
|
||||||
|
--reader-margin: var(--margin-width, 16px);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Three theme presets:
|
||||||
|
- **Sepia**: `bg:#f5f0eb`, `text:#1a1a1a` — warm, easy on eyes
|
||||||
|
- **Dark**: `bg:#1a1a2e`, `text:#e0e0e0` — for low-light reading
|
||||||
|
- **Light**: `bg:#ffffff`, `text:#1a1a1a` — crisp and clean
|
||||||
|
- **Paper**: `bg:#e8e0d4`, `text:#2c2c2c` — book-like feel
|
||||||
|
|
||||||
|
### Orientation Support
|
||||||
|
|
||||||
|
- CSS `@media (orientation: portrait)` and `@media (orientation: landscape)` breakpoints
|
||||||
|
- Reading settings panel includes orientation lock toggle
|
||||||
|
- On mobile, landscape mode expands content horizontally with wider margins
|
||||||
|
- Portrait mode stacks controls vertically for thumb-reachable UI
|
||||||
|
|
||||||
|
### Routing
|
||||||
|
|
||||||
|
Add to App.tsx:
|
||||||
|
```
|
||||||
|
/ → LibraryPage
|
||||||
|
/books/:bookId → BookDetailPage
|
||||||
|
/reader/:bookId → ReadingPage
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Backend models + migrations (Chapter, ReadingProgress, ReadingSettings)
|
||||||
|
2. Backend serializers + views + URLs
|
||||||
|
3. Frontend types + API client
|
||||||
|
4. Frontend hooks (useReadingSettings, useChapters, useReadingProgress)
|
||||||
|
5. Frontend components (ReadingSettingsPanel, TableOfContents, ReaderToolbar)
|
||||||
|
6. Frontend page (ReadingPage)
|
||||||
|
7. Routing updates
|
||||||
|
8. CSS / theming
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# 011 — Web reader (react-reader)
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Created:** 2026-06-03
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Replace the custom HTML chapter reader with [react-reader](https://github.com/gerhardsletten/react-reader) (epub.js) for paginated EPUB reading in the web app. Keep the existing toolbar and settings panel chrome. Block PDFs from the in-browser reader.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Library (EPUB only) → /read/:id → ReadingPage
|
||||||
|
→ GET /api/books/ebooks/{id}/ (metadata, format guard)
|
||||||
|
→ GET /api/books/ebooks/{id}/file/ (authenticated EPUB blob)
|
||||||
|
→ ReactReader (blob URL + CFI location)
|
||||||
|
→ PATCH /api/books/ebooks/{id}/progress/ (epub_location + percentage)
|
||||||
|
```
|
||||||
|
|
||||||
|
EPUB files are fetched with JWT via the API, converted to a blob URL client-side, and passed to react-reader. This avoids unauthenticated `/media/` URLs and CORS issues in dev.
|
||||||
|
|
||||||
|
## Backend changes
|
||||||
|
|
||||||
|
### `GET /api/books/ebooks/{id}/file/`
|
||||||
|
|
||||||
|
- Authenticated, owner-only
|
||||||
|
- Returns `FileResponse` with `Content-Type: application/epub+zip`
|
||||||
|
- Returns `400` if format is not `epub`
|
||||||
|
|
||||||
|
### `ReadingProgress.epub_location`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `epub_location` | CharField(2048) | EPUB CFI string for resume position |
|
||||||
|
|
||||||
|
Exposed on `GET/PATCH /api/books/ebooks/{id}/progress/` and in `EBookDetail.progress`.
|
||||||
|
|
||||||
|
`current_position` stores whole-book percentage (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
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# 012 — Library book context menu
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Created:** 2026-06-03
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Add a custom right-click context menu on each book card in the library with **Sync metadata** (Open Library refresh) and **Remove** (delete from library). Show toast notifications for success, warning, and error outcomes.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Library book card (contextmenu)
|
||||||
|
→ BookContextMenu
|
||||||
|
→ Sync metadata: POST /api/books/ebooks/{id}/enrich-metadata/
|
||||||
|
→ Remove: confirm dialog → DELETE /api/books/ebooks/{id}/
|
||||||
|
→ ToastProvider (app-wide) → success | warning | error toasts
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend endpoints already exist; no API changes required.
|
||||||
|
|
||||||
|
## Frontend changes
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `api/books.ts` | `enrichEBookMetadata(id)` → `POST .../enrich-metadata/` |
|
||||||
|
| `types/book.ts` | `OpenLibraryMetadata` with `match_status` |
|
||||||
|
| `hooks/useToast.tsx` | `ToastProvider`, `showToast({ message, variant })`, auto-dismiss ~4s |
|
||||||
|
| `components/ToastContainer.tsx` | Fixed bottom-right toast stack |
|
||||||
|
| `components/BookContextMenu.tsx` | Positioned menu; sync + remove actions |
|
||||||
|
| `pages/Library.tsx` | `onContextMenu` on cards; local state updates |
|
||||||
|
| `App.tsx` | Wrap routes with `ToastProvider` |
|
||||||
|
|
||||||
|
## Context menu behavior
|
||||||
|
|
||||||
|
- Opens on right-click (`contextmenu`); browser default menu suppressed
|
||||||
|
- Position clamped to viewport; closes on outside click, Escape, or scroll
|
||||||
|
- **Sync metadata:** updates card title/author/cover from response; toast by `metadata.match_status`:
|
||||||
|
- `matched` → success
|
||||||
|
- `not_found` → warning
|
||||||
|
- other → neutral success
|
||||||
|
- **Remove:** `window.confirm` before delete; removes card from list on success
|
||||||
|
- Menu clicks do not trigger card navigation to reader
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
None (no new npm packages).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Right-click a book → menu appears at cursor
|
||||||
|
2. Sync metadata on a matched book → cover/title may update, success toast
|
||||||
|
3. Sync on unmatched book → warning toast, no crash
|
||||||
|
4. Remove → confirm → book disappears, success toast
|
||||||
|
5. Cancel remove → book stays, menu closes
|
||||||
|
6. Left-click still opens reader (EPUB) or PDF notice
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 013 — Frontend i18n (react-i18n-lite)
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Created:** 2026-06-04
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Internationalize the web UI in English (`en-US`) and Spanish (`es-ES`) using [react-i18n-lite](https://www.npmjs.com/package/react-i18n-lite).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
App.tsx
|
||||||
|
└── I18nProvider (TranslationContainer)
|
||||||
|
├── resolveDefaultLanguage() — localStorage → navigator → en-US
|
||||||
|
├── LanguagePersistence — sync setLanguage ↔ localStorage, html[lang]
|
||||||
|
└── AuthProvider → ToastProvider → routes
|
||||||
|
```
|
||||||
|
|
||||||
|
Components call `useTranslation()` and `t('namespace.key', { interpolation })`.
|
||||||
|
|
||||||
|
## Locale files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `frontend/src/locales/en-US.ts` | English dictionary |
|
||||||
|
| `frontend/src/locales/es-ES.ts` | Spanish dictionary |
|
||||||
|
| `frontend/src/locales/index.ts` | `locales` map, `SupportedLanguage`, helpers |
|
||||||
|
|
||||||
|
Key namespaces: `common`, `auth`, `library`, `contextMenu`, `toast`, `settings`, `reader`, `addBook`, `bookDetail`, `search`, `annotations`.
|
||||||
|
|
||||||
|
## Language selection
|
||||||
|
|
||||||
|
- **Settings page** — UI Language dropdown (English / Español)
|
||||||
|
- **Persistence** — `localStorage` key `cloud-reader.locale`
|
||||||
|
- **Default** — saved preference, else `navigator.language` (`es*` → `es-ES`), else `en-US`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**Translated:** All user-facing chrome (library, auth, reader toolbar/settings/TOC, add book, book detail, annotations, context menu, toasts).
|
||||||
|
|
||||||
|
**Not translated:** EPUB body content, API error bodies, backend `reading_status_display`, Open Library metadata fields.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `react-i18n-lite` (^1.0.10)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Open app — labels match browser or saved language
|
||||||
|
2. Settings → switch to Español — library/auth update without reload
|
||||||
|
3. Refresh — language persists
|
||||||
|
4. Context menu and toasts show translated strings
|
||||||
|
5. `yarn build` succeeds
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 014 — Library reading progress badges
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Created:** 2026-06-04
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Show accurate reading state on library book cards using saved EPUB progress instead of always displaying "Want to Read" (`Quiero leer`).
|
||||||
|
|
||||||
|
## Data source
|
||||||
|
|
||||||
|
- `GET /api/books/ebooks/` returns `progress` (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
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# 015 — EPUB bookmarks and notes (physical-book UX)
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Created:** 2026-06-04
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Let users select passage text while reading an EPUB, save a bookmark (with optional thought), list markers in chapter order, and jump back to the exact location. Global view groups markers by book in a Reddit-style thread layout.
|
||||||
|
|
||||||
|
## Fix: AnnotationsProvider
|
||||||
|
|
||||||
|
`AnnotationsProvider` wraps all routes in [`App.tsx`](frontend/src/App.tsx) so `/bookmarks-notes` and the reader can use `useAnnotations()`.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
`Bookmark` (annotations app) references **`books.EBook`**, not catalog `Book`:
|
||||||
|
|
||||||
|
| Field | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `ebook` | FK to uploaded ebook |
|
||||||
|
| `epub_cfi` | EPUB CFI anchor |
|
||||||
|
| `chapter_index` | Spine index for sort order |
|
||||||
|
| `chapter_title` | Display label |
|
||||||
|
| `location_text` | Selected passage quote |
|
||||||
|
| `content` | Optional user thought (empty = bookmark only) |
|
||||||
|
| `page` | Legacy display field (`chapter_index + 1`) |
|
||||||
|
|
||||||
|
Unique: `(user, ebook, epub_cfi)`.
|
||||||
|
|
||||||
|
Default API ordering: `chapter_index`, `epub_cfi`.
|
||||||
|
|
||||||
|
Legacy `Note` model remains for old API; new UX uses `Bookmark.content` only.
|
||||||
|
|
||||||
|
## Reader flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant EpubView
|
||||||
|
participant Popover
|
||||||
|
participant API
|
||||||
|
User->>EpubView: Select text
|
||||||
|
EpubView->>Popover: Show near selection
|
||||||
|
User->>Popover: Save optional thought
|
||||||
|
Popover->>API: POST /annotations/bookmarks/
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Text selection via epub.js `selected` event and content `mouseup` hook.
|
||||||
|
2. [`SelectionPopover`](frontend/src/components/reader/SelectionPopover.tsx) — floating UI, optional textarea.
|
||||||
|
3. Toolbar bookmark icon opens [`BookMarkersPanel`](frontend/src/components/reader/BookMarkersPanel.tsx) (current ebook only).
|
||||||
|
4. “Go to passage” sets reader `location` to stored CFI (`/read/:id` with router state).
|
||||||
|
|
||||||
|
## Global page (`/bookmarks-notes`)
|
||||||
|
|
||||||
|
[`MarkerThreadsView`](frontend/src/components/annotations/MarkerThreadsView.tsx):
|
||||||
|
|
||||||
|
- Groups markers by ebook (collapsible book rows).
|
||||||
|
- Within each book: chapter order, passage as blockquote, thought indented below (Reddit-style).
|
||||||
|
- Optional filter: `/bookmarks-notes/:ebookId`.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- `GET /api/annotations/bookmarks/?ebook={id}`
|
||||||
|
- `POST /api/annotations/bookmarks/` — body: `ebook`, `epub_cfi`, `chapter_index`, `chapter_title`, `location_text`, `content`
|
||||||
|
|
||||||
|
## i18n
|
||||||
|
|
||||||
|
New keys under `annotations.*` (EN/ES): `saveMarker`, `thoughtPlaceholder`, `bookmarkOnly`, `selectTextHint`, `goToPassage`, `inBookPanel`, `markerCount`, etc.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `/bookmarks-notes` loads without provider error.
|
||||||
|
2. Select text in reader → popover → save with/without thought.
|
||||||
|
3. Markers appear in reader panel and global page under correct book, in chapter order.
|
||||||
|
4. “Go to passage” opens the correct location.
|
||||||
|
|
||||||
|
See also [016 — Bookmark reading anchor](016-bookmark-reading-anchor.md) for preserving reading position while peeking at bookmarks.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- PDF selection
|
||||||
|
- Multiple replies per passage
|
||||||
|
- Migrating legacy `Note` rows into `Bookmark`
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# 016 — Bookmark reading anchor
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Created:** 2026-06-04
|
||||||
|
**See also:** [015 — EPUB bookmarks and notes](015-epub-bookmarks-notes.md)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Let users jump to a bookmark to review a passage without overwriting their true reading position. While peeking, show a high-visibility control to return to where they were reading.
|
||||||
|
|
||||||
|
## Definitions
|
||||||
|
|
||||||
|
| Term | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| **Reading anchor** | EPUB CFI (+ optional %) captured immediately before a bookmark peek |
|
||||||
|
| **Peek mode** | Temporary view at a bookmark location; server `ReadingProgress` is not updated |
|
||||||
|
| **Resume** | Jump back to the reading anchor and re-enable progress persistence |
|
||||||
|
|
||||||
|
## Problem (before)
|
||||||
|
|
||||||
|
“Go to passage” called `jumpToCfi`, which triggered `locationChanged` and debounced `PATCH /books/ebooks/{id}/progress/`, replacing `epub_location` and `current_position` with the bookmark. Library progress and the next reading session started at the bookmark instead of the real position.
|
||||||
|
|
||||||
|
## Triggers (enter peek mode)
|
||||||
|
|
||||||
|
- In-reader: **Go to passage** in [`BookMarkersPanel`](../frontend/src/components/reader/BookMarkersPanel.tsx)
|
||||||
|
- Global: **Go to passage** on [`/bookmarks-notes`](../frontend/src/components/annotations/BookmarksNotesPage.tsx) → `/read/:id` with `state.epubLocation` (bookmark CFI)
|
||||||
|
|
||||||
|
## Non-triggers
|
||||||
|
|
||||||
|
- Table of contents navigation
|
||||||
|
- Prev / next page buttons
|
||||||
|
- Creating a new bookmark from text selection
|
||||||
|
- Opening the book normally from the library (no `epubLocation` in router state)
|
||||||
|
|
||||||
|
## UX
|
||||||
|
|
||||||
|
### Resume control
|
||||||
|
|
||||||
|
- Component: [`ResumeReadingButton`](../frontend/src/components/reader/ResumeReadingButton.tsx)
|
||||||
|
- Visible only when `isBookmarkPeekActive && readingAnchor != null`
|
||||||
|
- Position: right edge, **above** the next-page chevron (`.reader-page-nav--next`)
|
||||||
|
- Style: warm accent (`#ea580c` / `#f97316`), white label/icon; distinct from muted gray nav buttons
|
||||||
|
- Action: `resumeReadingAnchor()` — hides control, returns to anchor CFI
|
||||||
|
- i18n: `reader.resumeReading`, `reader.resumeReadingAria`
|
||||||
|
|
||||||
|
### Anchor policy
|
||||||
|
|
||||||
|
- First anchor is captured when peek starts; **additional “Go to passage” clicks while peeking do not replace the anchor** until the user resumes or leaves the reader.
|
||||||
|
|
||||||
|
## Progress rules
|
||||||
|
|
||||||
|
| Mode | `PATCH .../progress/` |
|
||||||
|
|------|------------------------|
|
||||||
|
| Normal reading | Yes (debounced on `locationChanged`, sync after locations ready) |
|
||||||
|
| Bookmark peek | **No** — `flushProgress` / `scheduleProgress` no-op |
|
||||||
|
| After resume | Yes — flush anchor CFI and percentage once |
|
||||||
|
|
||||||
|
### Load from bookmarks page
|
||||||
|
|
||||||
|
1. Fetch saved `ReadingProgress` from API.
|
||||||
|
2. If `state.epubLocation` is set and saved `epub_location` exists → store saved location as **anchor**, set peek mode, open at bookmark CFI.
|
||||||
|
3. Do not persist bookmark location as progress during peek.
|
||||||
|
|
||||||
|
### In-reader peek
|
||||||
|
|
||||||
|
1. Capture current `location` (CFI) as anchor (if valid).
|
||||||
|
2. Enter peek mode, jump to bookmark CFI.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
No backend changes in v1. Anchor is session-only in [`useEpubReader`](../frontend/src/hooks/useEpubReader.ts).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Read to ~30%, open markers, **Go to passage** on an early bookmark → jumps; after debounce, library/API progress still reflects ~30% (not bookmark).
|
||||||
|
2. Orange **Back to reading** appears above the next chevron only during peek.
|
||||||
|
3. Tap resume → returns to ~30%; button hides; progress saves resume.
|
||||||
|
4. From `/bookmarks-notes`, **Go to passage** → peek + resume using saved progress as anchor.
|
||||||
|
5. TOC / prev / next do not show the resume button.
|
||||||
|
6. EN/ES strings present.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- PDF reader
|
||||||
|
- Multiple anchor history stack
|
||||||
|
- Backend `resume_epub_location` field
|
||||||
|
- Peek mode for TOC jumps
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
# Frontend Component Specification — Cloud Reader
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
React 19 + TypeScript SPA using Vite for development and production builds. State managed via React hooks and Context API. Routing via `react-router-dom` v7 with `lazy`/`Suspense` for code splitting.
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
### App (root)
|
|
||||||
|
|
||||||
- **Path:** `/`
|
|
||||||
- **Layout:** Header with logo + `<Routes>` wrapper
|
|
||||||
- **Routing:** `/` → `Library`, `*` → redirect to `/`
|
|
||||||
- **Code splitting:** `Library` loaded via `React.lazy` + `<Suspense>`
|
|
||||||
|
|
||||||
### Library
|
|
||||||
|
|
||||||
- **State:** `books[]`, `stats`, `search`, `sortBy`, `statusFilter`, `currentPage`, `view`
|
|
||||||
- **Views:** `library` (grid), `detail` (selected book), `add` (modal form)
|
|
||||||
- **Sub-components:** `BookCard`, `BookDetail`, `BookForm`
|
|
||||||
- **Data flow:** Calls `fetchBooks()` on mount and when filters/page change
|
|
||||||
|
|
||||||
### BookCard
|
|
||||||
|
|
||||||
- **Props:** `{ book: Book, onClick: (book: Book) => void }`
|
|
||||||
- **Display:** Cover image (or first-letter placeholder), title, author, genre badge, reading progress bar with status color
|
|
||||||
- **Interaction:** Click/keyboard-accessible (Enter/Space)
|
|
||||||
|
|
||||||
### BookDetail
|
|
||||||
|
|
||||||
- **Props:** `{ bookId: number, onBack: () => void, onUpdated: () => void }`
|
|
||||||
- **Sections:** Cover, metadata (title, author, genre, ISBN), progress bar with page count, action buttons
|
|
||||||
- **Actions:** Mark as Finished, Edit Details (switches to BookForm), Delete (with confirmation)
|
|
||||||
- **States:** Loading, error, editing mode
|
|
||||||
|
|
||||||
### BookForm
|
|
||||||
|
|
||||||
- **Props:** `{ initialData?: Book, onSubmit: (data: BookFormData) => Promise<void>, onCancel: () => void }`
|
|
||||||
- **Fields:** Title*, Author*, Genre, Description, Cover URL, ISBN, Total Pages, Current Page, Status
|
|
||||||
- **Client validation:** Title/author required, page ≤ total pages
|
|
||||||
- **Loading state:** Submit button shows "Saving..." when `isLoading`
|
|
||||||
|
|
||||||
## Types
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Book {
|
|
||||||
id: number; title: string; author: string; genre: string;
|
|
||||||
description: string; cover_image_url: string; isbn: string;
|
|
||||||
total_pages: number; current_page: number;
|
|
||||||
reading_status: ReadingStatus; reading_progress: number;
|
|
||||||
owner: string; created_at: string; updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReadingStatus = 'not_started' | 'reading' | 'finished' | 'dnf';
|
|
||||||
|
|
||||||
interface BookFormData {
|
|
||||||
title: string; author: string; genre: string;
|
|
||||||
description: string; cover_image_url: string; isbn: string;
|
|
||||||
total_pages: number; current_page: number; reading_status: ReadingStatus;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Client (`api/books.ts`)
|
|
||||||
|
|
||||||
| Function | HTTP Call |
|
|
||||||
|----------------------|------------------------------------|
|
|
||||||
| `fetchBooks(params)` | `GET /api/books/` |
|
|
||||||
| `fetchBook(id)` | `GET /api/books/{id}/` |
|
|
||||||
| `createBook(data)` | `POST /api/books/` |
|
|
||||||
| `updateBook(id, data)` | `PATCH /api/books/{id}/` |
|
|
||||||
| `deleteBook(id)` | `DELETE /api/books/{id}/` |
|
|
||||||
| `markAsFinished(id)` | `POST /api/books/{id}/mark_finished/` |
|
|
||||||
| `fetchBookStats()` | `GET /api/books/stats/` |
|
|
||||||
|
|
||||||
## Styling
|
|
||||||
|
|
||||||
Dark theme with CSS custom properties. All styles in `App.css`. Responsive grid layout with breakpoint at 768px.
|
|
||||||
|
|
||||||
## Build & Dev
|
|
||||||
|
|
||||||
- `yarn workspace frontend dev` — Vite dev server on port 5173 with API proxy to 127.0.0.1:8000
|
|
||||||
- `yarn workspace frontend build` — TypeScript check + Vite production build
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Mobile Book Search & Discovery — Spec
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Enhance the existing book search experience with mobile-first features: voice search via the Web Speech API, real-time autocomplete suggestions, and touch-optimized responsive layout.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Backend endpoints already exist (from `docs/backend/search-discovery-spec.md`):
|
||||||
|
- `GET /api/books/?q=...&genre=...&author=...&reading_status=...` — paginated search
|
||||||
|
- `GET /api/books/{id}/` — book detail
|
||||||
|
- `GET /api/books/genres/` — genre discovery
|
||||||
|
- `GET /api/books/authors/` — author discovery
|
||||||
|
- Frontend `LibraryPage` and `BookDetailPage` components exist but lacked API client methods and types (fixed in this PR).
|
||||||
|
|
||||||
|
## Frontend API Client Additions
|
||||||
|
|
||||||
|
### `frontend/src/types/book.ts` — New exports
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface BookSearchParams {
|
||||||
|
q?: string;
|
||||||
|
genre?: string;
|
||||||
|
author?: string;
|
||||||
|
reading_status?: string;
|
||||||
|
ordering?: string;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const READING_STATUS_OPTIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: "", label: "All Statuses" },
|
||||||
|
{ value: "want_to_read", label: "Want to Read" },
|
||||||
|
{ value: "reading", label: "Reading" },
|
||||||
|
{ value: "finished", label: "Finished" },
|
||||||
|
{ value: "dnf", label: "Did Not Finish" },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### `frontend/src/api/books.ts` — New methods on `booksApi`
|
||||||
|
|
||||||
|
| Method | Endpoint | Returns |
|
||||||
|
|--------|----------|---------|
|
||||||
|
| `searchBooks(params)` | `GET /api/books/` | `{ count, results: BookListItem[] }` |
|
||||||
|
| `getBook(id)` | `GET /api/books/{id}/` | `BookDetail` |
|
||||||
|
| `getGenres()` | `GET /api/books/genres/` | `string[]` |
|
||||||
|
| `getAuthors()` | `GET /api/books/authors/` | `string[]` |
|
||||||
|
|
||||||
|
## Mobile Features
|
||||||
|
|
||||||
|
### 1. Voice Search
|
||||||
|
- **Hook**: `useVoiceSearch` in `frontend/src/hooks/useVoiceSearch.ts`
|
||||||
|
- Uses the Web Speech API (`SpeechRecognition` / `webkitSpeechRecognition`)
|
||||||
|
- Returns: `{ isListening, transcript, isSupported, startListening, stopListening, hasError }`
|
||||||
|
- Renders a microphone icon button next to the search input
|
||||||
|
- On mobile, tapping the mic icon triggers the native speech recognition prompt
|
||||||
|
- On success, populates the search input with the transcript and triggers a search
|
||||||
|
- Graceful degradation: if SpeechRecognition API is unavailable, the mic button is hidden
|
||||||
|
|
||||||
|
### 2. Real-Time Suggestions (Autocomplete)
|
||||||
|
- Component: `SearchSuggestions` rendered as a dropdown below the search input
|
||||||
|
- On each keystroke (debounced 200ms), fetches `GET /api/books/?q=...&page_size=5` for suggestions
|
||||||
|
- Shows up to 5 book title/author suggestions in a styled dropdown list
|
||||||
|
- Clicking a suggestion navigates directly to `/books/{id}`
|
||||||
|
- Clicking outside or pressing Escape dismisses the dropdown
|
||||||
|
- Combines with existing full search results — suggestions are fast previews, not the main result list
|
||||||
|
|
||||||
|
### 3. Mobile-Responsive Enhancements
|
||||||
|
- Filters panel is **collapsed by default** on mobile, toggleable via a "Filters" button
|
||||||
|
- Touch targets minimum 44px (WCAG 2.1)
|
||||||
|
- Results grid switches to **single column** below 600px viewport width
|
||||||
|
- Search input and filters panel stack vertically on small screens
|
||||||
|
- Add CSS breakpoints via inline styles and a `useMediaQuery` hook
|
||||||
|
- Bottom navigation-style action buttons on mobile (Add Book, Bookmarks, Settings become icon-only)
|
||||||
|
|
||||||
|
## Component Hierarchy
|
||||||
|
|
||||||
|
```
|
||||||
|
LibraryPage
|
||||||
|
├── Header (title, count, action buttons)
|
||||||
|
├── SearchInput
|
||||||
|
│ ├── TextInput (debounced 300ms)
|
||||||
|
│ ├── VoiceSearchButton (microphone icon)
|
||||||
|
│ └── SearchSuggestions (dropdown, debounced 200ms)
|
||||||
|
├── FiltersButton (mobile: toggle; desktop: always visible)
|
||||||
|
├── FiltersPanel (collapsible on mobile)
|
||||||
|
│ ├── GenreSelect
|
||||||
|
│ ├── AuthorSelect
|
||||||
|
│ ├── StatusSelect
|
||||||
|
│ └── ClearFiltersButton
|
||||||
|
├── LoadingState (skeleton grid)
|
||||||
|
├── ErrorState (message + retry button)
|
||||||
|
├── EmptyState (no results / no books)
|
||||||
|
└── ResultsGrid (responsive: auto-fill vs single column)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mobile-First CSS Strategy
|
||||||
|
- Use inline styles with `@media` queries in a shared `breakpoints.ts` utility
|
||||||
|
- Breakpoints: sm = 480px, md = 768px, lg = 1024px
|
||||||
|
- Base styles are mobile-first (single column, full width)
|
||||||
|
- Media queries expand to multi-column grid and horizontal layout on larger screens
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# 009 — Expo Mobile Application Integration
|
||||||
|
|
||||||
|
**Issue:** #16
|
||||||
|
**Status:** Draft
|
||||||
|
**Created:** 2026-05-29
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Integrate an Expo-based React Native mobile application into the `cloud-reader` monorepo, sharing types, API client patterns, and configuration with the existing web frontend.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
cloud-reader/
|
||||||
|
├── mobile/ # Expo React Native app
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── app.json
|
||||||
|
│ ├── tsconfig.json
|
||||||
|
│ ├── babel.config.js
|
||||||
|
│ ├── App.tsx # Root component
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── api/ # API client (mirrors frontend/src/api/ pattern)
|
||||||
|
│ │ │ ├── client.ts # Axios instance + JWT interceptor
|
||||||
|
│ │ │ ├── books.ts # Book API calls
|
||||||
|
│ │ │ └── annotations.ts
|
||||||
|
│ │ ├── screens/ # Screen-level components
|
||||||
|
│ │ ├── components/ # Reusable UI components
|
||||||
|
│ │ ├── navigation/ # React Navigation setup
|
||||||
|
│ │ ├── context/ # Auth context, etc.
|
||||||
|
│ │ ├── hooks/ # Custom hooks
|
||||||
|
│ │ └── types/ # Mobile-specific types
|
||||||
|
│ └── assets/
|
||||||
|
├── packages/
|
||||||
|
│ └── shared/
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── tsconfig.json
|
||||||
|
│ └── src/
|
||||||
|
│ ├── types.ts # Shared domain types (Book, User, Bookmark, Note)
|
||||||
|
│ └── utils.ts # Shared utility functions
|
||||||
|
└── package.json # Root — updated workspace config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monorepo Workspace Config
|
||||||
|
|
||||||
|
Root `package.json` workspaces array updated to include `"mobile"`, `"packages/shared"` alongside existing `"frontend"` and `"backend"`.
|
||||||
|
|
||||||
|
## Shared `packages/shared`
|
||||||
|
|
||||||
|
- `@cloud-reader/shared` package published within the monorepo
|
||||||
|
- Exports:
|
||||||
|
- All domain types (`Book`, `BookSummary`, `Bookmark`, `Note`, `User`, `AnnotationEntry`, `PaginatedResponse`, `TokenResponse`)
|
||||||
|
- API endpoint constants
|
||||||
|
- Date formatting helpers
|
||||||
|
- Validation utilities (email regex, password strength check)
|
||||||
|
|
||||||
|
## Mobile App Structure
|
||||||
|
|
||||||
|
### API Client (`mobile/src/api/client.ts`)
|
||||||
|
- Axios instance configured with:
|
||||||
|
- Base URL from environment variable (`EXPO_PUBLIC_API_URL`)
|
||||||
|
- JWT token attachment via request interceptor
|
||||||
|
- Token refresh response interceptor on 401
|
||||||
|
- Uses `AsyncStorage` for token persistence (instead of `localStorage`)
|
||||||
|
|
||||||
|
### Navigation (`mobile/src/navigation/`)
|
||||||
|
- React Navigation stack:
|
||||||
|
1. `AuthStack` — Login, Register screens
|
||||||
|
2. `MainTabs` — Library, Search, Settings tabs
|
||||||
|
3. `BookReader` — Full-screen reading view
|
||||||
|
|
||||||
|
### Key Screens
|
||||||
|
| Screen | Route | Purpose |
|
||||||
|
|--------|-------|---------|
|
||||||
|
| Login | `Auth/Login` | Email/password login |
|
||||||
|
| Register | `Auth/Register` | User registration |
|
||||||
|
| Library | `Main/Library` | Book list with filtering |
|
||||||
|
| BookDetail | `Main/BookDetail` | Book metadata + actions |
|
||||||
|
| Reader | `Reader/View` | EPUB/PDF rendering |
|
||||||
|
| Search | `Main/Search` | Book discovery |
|
||||||
|
| Settings | `Main/Settings` | Profile, theme, download mgmt |
|
||||||
|
|
||||||
|
## Backend Changes Required
|
||||||
|
|
||||||
|
None. The existing Django REST API already serves all endpoints needed by the mobile app. The mobile app communicates with the same backend via the shared API base URL.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
No changes to `docker-compose.yml` needed — the mobile app runs on-device or via Expo Go, not inside Docker.
|
||||||
|
|
||||||
|
## CI/CD Considerations
|
||||||
|
|
||||||
|
The monorepo structure supports a single pipeline that can:
|
||||||
|
- `yarn install` at root (installs all workspaces)
|
||||||
|
- `yarn workspace @cloud-reader/shared build`
|
||||||
|
- `yarn workspace @cloud-reader/mobile build` (Expo EAS for mobile builds)
|
||||||
|
- `yarn workspace @cloud-reader/frontend build` (Vite for web builds)
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# 010 — Mobile EPUB Reader
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Created:** 2026-06-04
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Mirror the web reading experience (`frontend/src/pages/ReadingPage.tsx` and
|
||||||
|
`frontend/src/components/reader/*`) inside the Expo app so users can open their
|
||||||
|
uploaded library, read EPUBs with persisted progress and typography settings,
|
||||||
|
and manage bookmarks/highlights — reusing the same Django REST API as the web
|
||||||
|
client.
|
||||||
|
|
||||||
|
The web EPUB renderer (`react-reader` / epub.js) is DOM-only, so mobile renders
|
||||||
|
EPUBs with `@epubjs-react-native/core`, which runs epub.js inside a
|
||||||
|
`react-native-webview`. This keeps behavior (CFI locations, themes, font
|
||||||
|
controls, TOC, annotations) close to web while staying inside the managed Expo
|
||||||
|
workflow.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Mirrored from web
|
||||||
|
|
||||||
|
- EPUB rendering with swipe pagination (`flow: "paginated"`).
|
||||||
|
- Resume position and debounced progress save (CFI + percentage).
|
||||||
|
- Table of contents drawer with jump-to-chapter.
|
||||||
|
- Reading settings: theme presets (light/sepia/paper/dark), font family, font
|
||||||
|
size, line spacing — persisted to `/api/reader/settings/`.
|
||||||
|
- Bookmarks: bookmark the current page, list/jump/delete, and create highlights
|
||||||
|
from a text selection. Highlights are re-applied on open (best-effort).
|
||||||
|
|
||||||
|
### Deferred (not in this pass)
|
||||||
|
|
||||||
|
- PDF reading. PDF books show a placeholder pointing to the web reader.
|
||||||
|
- Brightness and orientation-lock controls.
|
||||||
|
- Per-highlight color picker (highlights use a single default color).
|
||||||
|
- App-wide internationalization (web uses `react-i18n-lite`).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
LibraryScreen (EBook list)
|
||||||
|
| navigate("BookDetail", { ebookId })
|
||||||
|
v
|
||||||
|
BookDetailScreen ----------------> ReaderScreen ({ ebookId })
|
||||||
|
|
|
||||||
|
GET /api/books/ebooks/:id/ (format guard)
|
||||||
|
|
|
||||||
|
epub? --------------------- pdf? -> placeholder
|
||||||
|
|
|
||||||
|
EpubReaderView (ReaderProvider)
|
||||||
|
|
|
||||||
|
expo-file-system downloadAsync(file/, Bearer token) -> file:// uri
|
||||||
|
|
|
||||||
|
<Reader src=file:// fileSystem=useFileSystem flow="paginated" />
|
||||||
|
|
|
||||||
|
onLocationChange -> debounce 800ms -> PATCH progress/
|
||||||
|
onSelected -> POST bookmarks/ (+ highlight annotation)
|
||||||
|
useReader().toc -> goToLocation(href)
|
||||||
|
settings change -> changeTheme / changeFontSize / changeFontFamily
|
||||||
|
+ PATCH /api/reader/settings/ (debounced)
|
||||||
|
```
|
||||||
|
|
||||||
|
The ebook file is downloaded to the app cache with an `Authorization` header
|
||||||
|
(the `/file/` endpoint is JWT-protected) and the local `file://` URI is handed
|
||||||
|
to the renderer — mirroring how the web client downloads a blob rather than
|
||||||
|
using a public/signed URL.
|
||||||
|
|
||||||
|
## Mobile changes
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|------|------|
|
||||||
|
| `mobile/src/api/client.ts` | Adds `apiClient`, `saveTokens`, `loadTokens`, `getApiBaseUrl` |
|
||||||
|
| `mobile/src/api/ebooks.ts` | `/api/books/ebooks/` list/detail/toc + `getFileUrl(id)` |
|
||||||
|
| `mobile/src/api/reader.ts` | Reader settings + per-book progress (mirrors web `api/reader.ts`) |
|
||||||
|
| `mobile/src/api/annotations.ts` | Bookmarks CRUD against `/api/annotations/bookmarks/` |
|
||||||
|
| `mobile/src/types/reader.ts` | `ReadingSettings`, `ReadingProgress` (full reader shapes) |
|
||||||
|
| `mobile/src/types/index.ts` | `AppStackParamList`, `Bookmark`, `CreateMarkerPayload` |
|
||||||
|
| `mobile/src/hooks/useReadingSettings.ts` | Loads + debounced-saves reader settings |
|
||||||
|
| `mobile/src/utils/epubTheme.ts` | Font stacks, theme palettes, `buildEpubTheme()` |
|
||||||
|
| `mobile/src/navigation/AppStack.tsx` | Native stack: Tabs / BookDetail / Reader |
|
||||||
|
| `mobile/src/screens/LibraryScreen.tsx` | Lists user EBooks (`ebooksApi.list`) |
|
||||||
|
| `mobile/src/screens/BookDetailScreen.tsx` | Metadata + start/resume button |
|
||||||
|
| `mobile/src/screens/ReaderScreen.tsx` | Format guard: EPUB view vs PDF placeholder |
|
||||||
|
| `mobile/src/components/reader/EpubReaderView.tsx` | Reader, progress, bookmarks, settings wiring |
|
||||||
|
| `mobile/src/components/reader/ReaderToolbar.tsx` | Title, chapter, progress bar, action buttons |
|
||||||
|
| `mobile/src/components/reader/TocModal.tsx` | Table of contents sheet |
|
||||||
|
| `mobile/src/components/reader/ReadingSettingsModal.tsx` | Theme/font/size/spacing controls |
|
||||||
|
| `mobile/src/components/reader/BookmarksModal.tsx` | Bookmarks & highlights list |
|
||||||
|
| `mobile/App.tsx` | Wraps the tree in `GestureHandlerRootView` |
|
||||||
|
|
||||||
|
Removed unused scaffolding: `mobile/src/navigation/AppNavigator.tsx`,
|
||||||
|
`mobile/src/navigation/MainTabs.tsx`.
|
||||||
|
|
||||||
|
## API contracts (consumed)
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| GET | `/api/books/ebooks/` | User library list |
|
||||||
|
| GET | `/api/books/ebooks/:id/` | EBook detail (format, progress, cover) |
|
||||||
|
| GET | `/api/books/ebooks/:id/file/` | Stream EPUB bytes (JWT, owner) |
|
||||||
|
| GET/PATCH | `/api/books/ebooks/:id/progress/` | Reading progress (`current_position`, `last_page`, `epub_location`) |
|
||||||
|
| GET/PATCH | `/api/reader/settings/` | Reader typography/theme settings |
|
||||||
|
| GET/POST/DELETE | `/api/annotations/bookmarks/` | Bookmarks & highlights (`ebook`, `epub_cfi`, `chapter_index`, ...) |
|
||||||
|
|
||||||
|
## Settings mapping (web -> mobile)
|
||||||
|
|
||||||
|
| Reader setting | Web (epub.js) | Mobile (`@epubjs-react-native/core`) |
|
||||||
|
|----------------|---------------|--------------------------------------|
|
||||||
|
| `theme` / colors | `themes.register/select` | `changeTheme(buildEpubTheme())` + `defaultTheme` |
|
||||||
|
| `font_size` | `themes.fontSize` | `changeFontSize("Npx")` |
|
||||||
|
| `font_family` | body font-family | `changeFontFamily(stack)` |
|
||||||
|
| `line_height` | body line-height | `buildEpubTheme()` CSS rule |
|
||||||
|
| `margin_width` | gap-based padding | not applied (deferred) |
|
||||||
|
| `brightness` / `orientation_lock` | applied on web | deferred |
|
||||||
|
|
||||||
|
## Dependencies added
|
||||||
|
|
||||||
|
- `@epubjs-react-native/core@1.4.7`
|
||||||
|
- `@epubjs-react-native/expo-file-system@1.1.4`
|
||||||
|
- `react-native-webview@13.12.5`
|
||||||
|
|
||||||
|
(`react-native-gesture-handler`, `react-native-reanimated`, and
|
||||||
|
`expo-file-system` were already present.)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Env var | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `EXPO_PUBLIC_API_URL` | Backend base URL (e.g. `http://10.0.2.2:8000` on Android emulator, LAN IP on a device) |
|
||||||
|
|
||||||
|
## Compatibility notes
|
||||||
|
|
||||||
|
- The Expo file-system adapter (`@epubjs-react-native/expo-file-system`) depends
|
||||||
|
only on `expo-file-system`, so the reader runs in Expo Go. (The library's
|
||||||
|
bare adapter pulls native `@dr.pogodin/react-native-fs`; that path is not
|
||||||
|
used here.)
|
||||||
|
- React 19 / Expo SDK 52 may surface peer-dependency warnings for the
|
||||||
|
`@epubjs-react-native/*` packages.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- [ ] Log in; Library lists the user's uploaded EBooks with covers/progress.
|
||||||
|
- [ ] Open an EPUB; it renders and paginates by swipe.
|
||||||
|
- [ ] Reopen a book; it resumes at the last position.
|
||||||
|
- [ ] Change theme/font/size/spacing; the page updates and persists across reopen.
|
||||||
|
- [ ] Open the TOC and jump to a chapter.
|
||||||
|
- [ ] Bookmark the current page; it appears in the bookmarks list and can be re-opened/deleted.
|
||||||
|
- [ ] Select text to create a highlight; it persists and re-renders on reopen.
|
||||||
|
- [ ] Open a PDF book; the placeholder is shown instead of a crash.
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
RUN yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY . ./
|
||||||
|
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
CMD ["yarn", "dev", "--host"]
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# React + TypeScript + Vite
|
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
|
||||||
|
|
||||||
## React Compiler
|
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs['recommended-typescript'],
|
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
import{X as j,h as S,r as d,j as e}from"./index-BQiEVoRj.js";import{b as B}from"./books-BG7wMiRx.js";import{g as v}from"./errors-bLl0IgZb.js";function R(){const{t:o}=j(),s=S(),[a,p]=d.useState(""),[c,b]=d.useState(""),[n,u]=d.useState(null),[r,f]=d.useState(!1),[x,i]=d.useState(null),y=t=>{var g,h;const l=((g=t.target.files)==null?void 0:g[0])??null;if(l){const m=(h=l.name.split(".").pop())==null?void 0:h.toLowerCase();if(m!=="epub"&&m!=="pdf"){i(o("addBook.fileTypeError")),u(null);return}u(l),i(null),a||p(l.name.replace(/\.(epub|pdf)$/i,"").slice(0,512))}},k=async t=>{if(t.preventDefault(),!n||!a.trim()){i(o("addBook.requiredError"));return}f(!0),i(null);try{await B.uploadEBook(n,a.trim(),c.trim()),s("/")}catch(l){i(v(l,o("addBook.uploadFailed")))}finally{f(!1)}};return e.jsxs("div",{style:{maxWidth:500,margin:"0 auto",padding:16,minHeight:"100vh",background:"#f8f9fa"},children:[e.jsxs("header",{style:{display:"flex",alignItems:"center",gap:12,marginBottom:24,padding:"16px 0",borderBottom:"1px solid #eee"},children:[e.jsxs("button",{onClick:()=>s("/"),style:{padding:"8px 16px",borderRadius:6,border:"1px solid #ddd",background:"#fff",cursor:"pointer",fontSize:14},children:["← ",o("common.back")]}),e.jsx("h1",{style:{fontSize:24,fontWeight:700,color:"#1a1a2e",margin:0},children:o("addBook.title")})]}),e.jsxs("form",{onSubmit:k,style:{display:"flex",flexDirection:"column",gap:20},children:[x&&e.jsx("div",{style:{background:"#fde8e8",padding:12,borderRadius:6,color:"#e74c3c",fontSize:14},children:x}),e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[e.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:o("addBook.fileLabel")}),e.jsx("input",{type:"file",accept:".epub,.pdf",onChange:y,style:{padding:"10px 0"}}),n&&e.jsx("p",{style:{fontSize:13,color:"#666",marginTop:4},children:o("addBook.selected",{name:n.name})})]}),e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[e.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:o("addBook.titleLabel")}),e.jsx("input",{type:"text",value:a,onChange:t=>p(t.target.value),placeholder:o("addBook.titlePlaceholder"),style:{padding:"10px 12px",borderRadius:6,border:"1px solid #ddd",fontSize:16,outline:"none"}})]}),e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[e.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:o("addBook.authorLabel")}),e.jsx("input",{type:"text",value:c,onChange:t=>b(t.target.value),placeholder:o("addBook.authorPlaceholder"),style:{padding:"10px 12px",borderRadius:6,border:"1px solid #ddd",fontSize:16,outline:"none"}})]}),e.jsx("button",{type:"submit",disabled:r,style:{padding:"12px 24px",borderRadius:8,border:"none",background:"#1a1a2e",color:"#fff",fontSize:16,fontWeight:600,cursor:"pointer",opacity:r?.6:1,marginTop:8},children:o(r?"addBook.uploading":"addBook.upload")})]})]})}export{R as AddBookPage};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
import{j as t,X as v,r as o,e as w,h as z}from"./index-BQiEVoRj.js";import{g as A}from"./errors-bLl0IgZb.js";function h({isLogin:n,onToggle:p}){const{t:e}=v(),[i,g]=o.useState(""),[a,f]=o.useState(""),[d,x]=o.useState(""),[c,s]=o.useState(null),[l,u]=o.useState(!1),{login:m,register:y}=w(),b=z(),j=async r=>{if(r.preventDefault(),s(null),!n&&a!==d){s(e("auth.passwordsMismatch"));return}u(!0);try{n?await m(i,a):await y(i,a),b("/")}catch(S){s(A(S,e(n?"auth.loginFailed":"auth.registrationFailed")))}finally{u(!1)}};return t.jsx("div",{style:{display:"flex",justifyContent:"center",alignItems:"center",minHeight:"100vh",background:"#f8f9fa",padding:16},children:t.jsxs("div",{style:{width:"100%",maxWidth:400,background:"#fff",borderRadius:12,padding:32,boxShadow:"0 2px 16px rgba(0,0,0,0.08)"},children:[t.jsx("h1",{style:{fontSize:28,fontWeight:700,color:"#1a1a2e",textAlign:"center",marginBottom:4},children:e("common.appName")}),t.jsx("h2",{style:{fontSize:16,color:"#888",textAlign:"center",marginBottom:24,fontWeight:400},children:e(n?"auth.signIn":"auth.createAccount")}),t.jsxs("form",{onSubmit:j,style:{display:"flex",flexDirection:"column",gap:16},children:[c&&t.jsx("div",{style:{background:"#fde8e8",padding:12,borderRadius:6,color:"#e74c3c",fontSize:14},children:c}),t.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[t.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:e("auth.email")}),t.jsx("input",{type:"email",value:i,onChange:r=>g(r.target.value),placeholder:e("auth.emailPlaceholder"),required:!0,style:{padding:"10px 12px",borderRadius:6,border:"1px solid #ddd",fontSize:16,outline:"none"}})]}),t.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[t.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:e("auth.password")}),t.jsx("input",{type:"password",value:a,onChange:r=>f(r.target.value),placeholder:e("auth.passwordPlaceholder"),required:!0,minLength:8,style:{padding:"10px 12px",borderRadius:6,border:"1px solid #ddd",fontSize:16,outline:"none"}})]}),!n&&t.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[t.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:e("auth.confirmPassword")}),t.jsx("input",{type:"password",value:d,onChange:r=>x(r.target.value),placeholder:e("auth.confirmPasswordPlaceholder"),required:!0,minLength:8,style:{padding:"10px 12px",borderRadius:6,border:"1px solid #ddd",fontSize:16,outline:"none"}})]}),t.jsx("button",{type:"submit",disabled:l,style:{padding:"12px 24px",borderRadius:8,border:"none",background:"#1a1a2e",color:"#fff",fontSize:16,fontWeight:600,cursor:"pointer",opacity:l?.6:1,marginTop:8},children:e(l?n?"auth.signingIn":"auth.creatingAccount":n?"auth.signIn":"auth.createAccount")})]}),t.jsxs("p",{style:{textAlign:"center",marginTop:20,color:"#888",fontSize:14},children:[n?e("auth.noAccount")+" ":e("auth.hasAccount")+" ",t.jsx("button",{onClick:p,style:{background:"none",border:"none",color:"#1a1a2e",fontWeight:600,cursor:"pointer",fontSize:14,textDecoration:"underline"},children:e(n?"auth.register":"auth.signIn")})]})]})})}function W({onToggle:n}){return t.jsx(h,{isLogin:!0,onToggle:n})}function k({onToggle:n}){return t.jsx(h,{isLogin:!1,onToggle:n})}export{W as LoginPage,k as RegisterPage};
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
import{X as m,u,r as l,j as e,h as g,i as j}from"./index-BQiEVoRj.js";function N({ebookIdFilter:n,onGoToPassage:i}){const{t:s}=m(),{markersByBook:r,loadBookmarks:c,removeBookmark:k,state:h}=u(),[p,x]=l.useState({});l.useEffect(()=>{c(n)},[c,n]);const d=l.useMemo(()=>{if(!n)return r;const a=Number(n);return r.filter(o=>o.ebookId===a)},[r,n]);if(h.bookmarksLoading)return e.jsx("div",{className:"annotations-loading",children:s("annotations.loading")});if(d.length===0)return e.jsxs("div",{className:"annotations-empty",children:[e.jsx("p",{children:s("annotations.empty")}),e.jsx("p",{style:{fontSize:14,color:"#6b7280",marginTop:8},children:s("annotations.selectTextHint")})]});const b=a=>{x(o=>({...o,[a]:!o[a]}))};return e.jsx("div",{className:"marker-books-list",children:d.map(a=>{const o=p[a.ebookId]??!1;return e.jsxs("section",{className:"marker-book-group",children:[e.jsxs("button",{type:"button",className:"marker-book-header",onClick:()=>b(a.ebookId),children:[e.jsx("span",{className:"marker-book-title",children:a.ebookTitle}),e.jsx("span",{className:"marker-book-count",children:s("annotations.markerCount",{count:String(a.markers.length)})}),e.jsx("span",{className:"marker-book-chevron",children:o?"▸":"▾"})]}),!o&&e.jsx("ul",{className:"marker-thread-list",children:a.markers.map(t=>e.jsxs("li",{className:"marker-thread",children:[e.jsx("div",{className:"marker-thread-meta",children:t.chapter_title?e.jsx("span",{className:"marker-thread-chapter",children:t.chapter_title}):e.jsx("span",{className:"marker-thread-chapter",children:s("annotations.chapterIndex",{index:String(t.chapter_index+1)})})}),t.location_text&&e.jsxs("blockquote",{className:"annotation-quote marker-thread-passage",children:["“",t.location_text,"”"]}),t.content?e.jsx("p",{className:"marker-thread-thought marker-thread-reply",children:t.content}):e.jsx("span",{className:"annotation-kind-badge bookmark-badge",children:s("annotations.bookmarkOnly")}),e.jsxs("div",{className:"annotation-actions",children:[e.jsx("button",{type:"button",className:"btn btn-sm",onClick:()=>i(t.ebook_id,t.epub_cfi),children:s("annotations.goToPassage")}),e.jsx("button",{type:"button",className:"btn btn-sm btn-danger",onClick:()=>k(t.id),children:s("common.delete")})]})]},t.id))})]},a.ebookId)})})}function y(){const{t:n}=m(),i=g(),{bookId:s}=j();return e.jsxs("div",{className:"page bookmarks-notes-page",style:{maxWidth:720,margin:"0 auto",padding:16},children:[e.jsxs("header",{style:{display:"flex",alignItems:"center",gap:12,marginBottom:24},children:[e.jsxs("button",{type:"button",onClick:()=>i("/"),style:{padding:"8px 16px",borderRadius:6,border:"1px solid #ddd",background:"#fff",cursor:"pointer",fontSize:14},children:["← ",n("common.back")]}),e.jsx("h2",{style:{margin:0,fontSize:22,fontWeight:700},children:n("annotations.title")})]}),e.jsx(N,{ebookIdFilter:s,onGoToPassage:(r,c)=>{i(`/read/${r}`,{state:{epubLocation:c}})}})]})}export{y as BookmarksNotesPage};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
._container_1fxkh_1{position:absolute;top:100%;left:0;right:0;z-index:100;background:#fff;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 10px 10px;box-shadow:0 8px 24px #0000001f;max-height:320px;overflow-y:auto}._infoText_1fxkh_31{padding:12px 16px;color:#9ca3af;font-size:13px}._suggestionItem_1fxkh_43{display:flex;align-items:center;gap:12px;padding:10px 16px;cursor:pointer;border-bottom:1px solid #f3f4f6;min-height:44px}._suggestionItem_1fxkh_43:hover{background:#f9fafb}._coverImage_1fxkh_71{width:32px;height:48px;object-fit:cover;border-radius:4px}._coverPlaceholder_1fxkh_85{font-size:20px;flex-shrink:0}._bookInfo_1fxkh_95{min-width:0}._bookTitle_1fxkh_103{font-size:14px;font-weight:600;color:#1f2937;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}._bookAuthor_1fxkh_121{font-size:12px;color:#6b7280;margin-top:2px}._menu_ekpcj_1{position:fixed;z-index:9999;min-width:180px;background:#fff;border:1px solid #e5e7eb;border-radius:8px;box-shadow:0 4px 20px #0000001f;padding:4px 0;overflow:hidden}._item_ekpcj_25{display:block;width:100%;padding:10px 16px;border:none;background:transparent;text-align:left;font-size:14px;color:#374151;cursor:pointer;transition:background .1s}._item_ekpcj_25:hover:not(:disabled){background:#f3f4f6}._item_ekpcj_25:disabled{opacity:.5;cursor:not-allowed}._itemDanger_ekpcj_69{color:#dc2626}._itemDanger_ekpcj_69:hover:not(:disabled){background:#fef2f2}._itemLoading_ekpcj_85{color:#6b7280}._separator_ekpcj_93{height:1px;background:#e5e7eb;margin:4px 0}._bookCard_11p5a_1{background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px #0000000f;cursor:pointer;transition:transform .15s,box-shadow .15s}._bookCard_11p5a_1:hover{transform:translateY(-2px);box-shadow:0 4px 16px #0000001a}._coverProgressBar_11p5a_29{position:absolute;left:0;right:0;bottom:0;height:4px;background:#0000001f}._coverProgressFill_11p5a_47{height:100%;background:#16a34a;transition:width .2s ease}
|
||||||
+1
File diff suppressed because one or more lines are too long
+22
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user