Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bc149f3d9 | ||
|
|
f981e72ff8 | ||
|
|
199d82c613 | ||
|
|
0914c935d9 | ||
|
|
5a2c1585ae | ||
|
|
7a604c2fec | ||
|
|
be10adc24b | ||
|
|
6457fbaf72 | ||
|
|
8de8883acf | ||
|
|
0a7fbd6f48 | ||
|
|
df556d8208 | ||
|
|
d95814ded3 | ||
|
|
17274bb868 | ||
|
|
f42720ac64 | ||
|
|
320f31086d | ||
|
|
dc353b0f3f | ||
|
|
2a6e4b9ae5 | ||
|
|
505fc48b23 | ||
|
|
57d83fd9af | ||
|
|
0772f06d25 | ||
|
|
1601548e6c | ||
|
|
740213f0a4 | ||
|
|
bc6ce41762 | ||
|
|
f3c9d4b4e8 | ||
|
|
300866ca67 | ||
|
|
431fec05ad | ||
|
|
97fbdf19fb | ||
|
|
a36cee4f85 | ||
|
|
14947608be | ||
|
|
03a6040360 | ||
|
|
93a468b435 | ||
|
|
bb3aef517c | ||
|
|
cbdddc095e | ||
|
|
5a0d9e0e17 | ||
|
|
19619ef817 | ||
|
|
a28ae5c88d | ||
|
|
1c209408cc | ||
|
|
93f020f2b5 | ||
|
|
a3ecac3507 | ||
|
|
db3b5b3d62 | ||
|
|
f69e659d94 | ||
|
|
dbeefb395c | ||
|
|
8acaeddda5 | ||
|
|
361fdaab39 | ||
|
|
4d1fb05e49 | ||
|
|
679e0f9116 | ||
|
|
9cd6356892 | ||
|
|
4f05a232fe | ||
|
|
69516ae165 | ||
|
|
f24a2d6c22 | ||
|
|
da13898046 | ||
|
|
1a2d42a244 | ||
|
|
208fc2c4d6 | ||
|
|
18969a0f01 | ||
|
|
843f35bbfc | ||
|
|
7ce73213ae | ||
|
|
31f939002f | ||
|
|
f83b36167e | ||
|
|
d5d8e3e173 | ||
|
|
44c0e5640f | ||
|
|
e7ccd7b222 | ||
|
|
b1de0da512 | ||
|
|
25d44452c0 | ||
|
|
b8db1d109c | ||
|
|
bbecd1e5ec | ||
|
|
ffe690691b | ||
|
|
0708a1f414 | ||
|
|
c8e85817aa | ||
|
|
f45adc6362 | ||
|
|
5275f2ba38 | ||
|
|
0b83db8fce | ||
|
|
4b5e193d9f |
No files matched your search
@@ -79,3 +79,6 @@ web/static/vendor/
|
||||
VALID_TOKEN
|
||||
fresh_token.txt
|
||||
session-*.md
|
||||
|
||||
# Local prod-export scratch: personal annotation/reading data, never commit
|
||||
/export/
|
||||
@@ -0,0 +1,123 @@
|
||||
# AGENTS.md — Bookhoard server
|
||||
|
||||
Standing rules for any agent or developer working in this repo.
|
||||
Project-wide context lives in the sibling repos' AGENTS.md files
|
||||
(../bookhoard-app/AGENTS.md is the Android client's).
|
||||
|
||||
## Library visibility (family-lens rule) — ALWAYS apply
|
||||
|
||||
Per-user library visibility (`library_visibility` table, default-ALLOW:
|
||||
every user sees every library unless an admin hides one) must be
|
||||
enforced on **every endpoint that returns media metadata or content**.
|
||||
It is NOT automatic — enforcement is per-handler.
|
||||
|
||||
- **New endpoints that list/filter media or series** MUST filter by the
|
||||
requesting user's visible-library set, and MUST reject an explicit
|
||||
`library_id` outside that set with 403 (never silently pass).
|
||||
- Helpers (do not hand-roll):
|
||||
- `handlers`: `(*MediaHandler).visibleLibrarySet` /
|
||||
`(*SeriesHandler).visibleLibrarySet` — membership map + id slice.
|
||||
- `router`: `visibleLibraryIDsFor` — same for SSR routes.
|
||||
- `resolveLibrary` (helpers.go) only honors requested library ids
|
||||
inside the visible set.
|
||||
- SQL pattern: `AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR
|
||||
library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))` — see
|
||||
`ListMediaItems`, `GetDistinctSeries`, `SearchMediaItems`.
|
||||
- Content endpoints (file bytes, page images) additionally gate per item
|
||||
like `ReaderHandler.ensureItemVisible` / the download endpoint.
|
||||
- Tests must pin visibility: hidden library → 403 on the endpoint, and
|
||||
absent from unscoped aggregates.
|
||||
|
||||
History: 2026-10-13 (`431fec0`) the series/media listing surfaces
|
||||
leaked hidden libraries to any authenticated account — series endpoints
|
||||
were name-only across ALL libraries. Fixed; do not regress.
|
||||
|
||||
## Tests
|
||||
|
||||
- Use `setupTestServer()` from `cmd/server/tests/test_helpers_test.go`
|
||||
(see the Conventions section below for the pool/cleanup rules).
|
||||
- Integration tests run against the SHARED dev database: never write
|
||||
destructive SQL without preserving the dev rows (libraries Manga/
|
||||
Comics/Ebooks, testuser's annotations and progress are real data).
|
||||
- Test-created users must use `@tests.bookhoard.internal` or
|
||||
`@example.com` emails and "test"-named collections/libraries so the
|
||||
harness cleanup can find them.
|
||||
- **NEVER rename or edit the shared admin (`testuser`) in tests** — a
|
||||
renamed admin escapes the cleanup preserve-list and cascades real
|
||||
data (this wiped the dev annotations once; see user_test.go
|
||||
TestUpdateProfileSelfEdit for the dedicated-user pattern).
|
||||
- **`testuser` is a LIVE reader**: the dev account genuinely reads in
|
||||
the web reader while the suite runs, so `reading_history` /
|
||||
`reading_progress` rows accrue on it mid-test. Exact-total
|
||||
assertions against that account flap (three stats/analytics tests
|
||||
broke on 2026-10-14 within hours of session capture shipping).
|
||||
Tests that need deterministic data use `setup.RegularToken` +
|
||||
`getRegularUserID` (the fresh per-test user), or assert ranges
|
||||
instead of equals. Related: **no table-wide DELETEs in test
|
||||
cleanups** — sync_integration's `DELETE FROM reading_history WHERE
|
||||
true` destroyed the live reader's stats data on every suite run
|
||||
until scoped to the test-sync users (cascade via user delete).
|
||||
|
||||
## Timezones
|
||||
|
||||
- The server owns display conversion: clients send instants (RFC3339),
|
||||
the server renders in the requesting user's timezone.
|
||||
- Use `utils.FormatTimeInTimezone` / `utils.UserLocation` — never
|
||||
`time.Now().Format(...)` bare (that is server-UTC).
|
||||
- Analytics day-buckets must bucket in the user's timezone
|
||||
(`CreatedAt.In(loc)`), and date-range end bounds must be end-of-day.
|
||||
|
||||
## Known gotchas (do not relearn)
|
||||
|
||||
1. **templ does NOT interpolate `{ expr }` inside quoted attribute
|
||||
values** — it emits the literal text. `x-init="f('{ col.ID }')"`
|
||||
shipped a request to `/api/collections/{ collection.ID }` found by
|
||||
the request logs; the whole attribute value must BE an expression:
|
||||
`x-init={ "f('" + col.ID + "')" }`. Note
|
||||
`class="theme-{ user.Theme }"` has always been literal (harmlessly
|
||||
masked by the JS theme engine).
|
||||
2. **SettingsRegistry caches**: writes must go through the settings
|
||||
API (Reload on save). Direct SQL writes to system_settings leave
|
||||
the cache stale until the next API-path write — never "preview"
|
||||
rule changes via psql and expect them applied (2026-10-14: a live
|
||||
curl test flip looked ignored until the API path was used).
|
||||
3. **User collections are not dashboard sections**: nothing sets
|
||||
collection show_on_dashboard for user rows, and GetDashboardSections
|
||||
skips empty user sections. Collection detail (JSON + SSR) must NOT
|
||||
route user rows through the dashboard-section path — a 7ce7321
|
||||
regression did exactly that (static detail without library_id
|
||||
returned zero books until 0a7fbd6). Only real system query types
|
||||
(continue-reading etc.) route there; `query_type='filter'` is the
|
||||
schema DEFAULT on every user row, not a query type.
|
||||
4. **End-of-day arithmetic**: anchor the END bound to the START of the
|
||||
day, never to NOW(). `now.AddDate(0,0,1) − 1ns` carries this
|
||||
instant's clock time into tomorrow — a range "ending today"
|
||||
resolves tomorrow's calendar date and today's rows land in
|
||||
yesterday's bucket (found twice now: analytics 1601548 and stats
|
||||
2026-10-14). Same trap, two deaths.
|
||||
5. **Test binaries run against the LIVE dev DB**: hand-probes made in
|
||||
psql during debugging (seed rows, fake sessions) will silently
|
||||
pollute the next integration run — DELETE them when done (this
|
||||
burned the first stats suite run: a stray 30-min probe row shipped
|
||||
a phantom "185 minutes" failure).
|
||||
6. **SaveProgressRequest.DeviceID is NOT always a device**: the
|
||||
web/app progress paths have historically stuffed the USER id into
|
||||
it (it only fed a broadcast label before stats). Anything that
|
||||
dereferences it (reading_history device_id FK, device joins) must
|
||||
probe DeviceExistsForUser first — stats capture does.
|
||||
|
||||
## Conventions (absorbed from the retired PROJECT_GUIDELINES.md —
|
||||
## only the rules that still matched reality)
|
||||
|
||||
- **Runtime is Docker** (Makefile auto-detects docker, falls back to
|
||||
podman). The old "podman only" rule is dead.
|
||||
- **The server binary is only built inside the Docker build**
|
||||
(`make rebuild-app`); host-side Go builds are for tests/vet only.
|
||||
- **Schema changes go in place** — edit `database/schema/schema.sql`,
|
||||
never create migration files, until a release splits them.
|
||||
- **Business logic lives in services**, not handlers; handlers parse
|
||||
requests and call services (all DB access goes through services).
|
||||
- **Integration tests**: one `setupTestServer()` per test function;
|
||||
test pools use `max_conns=1`; never create a `pgxpool` per subtest
|
||||
(connection exhaustion); use `t.Cleanup()`-based helpers; table-driven
|
||||
subtests over duplicate functions.
|
||||
@@ -1065,7 +1065,7 @@ If there's a section about documentation features, mention the new search functi
|
||||
|
||||
## Git Commit Strategy
|
||||
|
||||
Follow PROJECT_GUIDELINES.md - make multiple logical commits:
|
||||
Follow AGENTS.md conventions - make multiple logical commits:
|
||||
|
||||
1. **Backend implementation** (search.go, http_handler.go, router changes)
|
||||
2. **Unit tests** (search_test.go)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: help test test-integration test-all rebuild rebuild-force rebuild-app rebuild-app-force rebuild-force-db clean restart up down logs ps test-env-up test-env-down verify-guidelines verify-quick release
|
||||
.PHONY: help test test-integration test-all rebuild-app rebuild-app-force rebuild rebuild-force rebuild-force-db clean restart up down logs ps test-env-up test-env-down release
|
||||
|
||||
# Include .env file for environment variables (single source of truth)
|
||||
# Ignore if .env doesn't exist yet
|
||||
@@ -42,8 +42,6 @@ help:
|
||||
@echo " make test-env-down - Stop test environment"
|
||||
@echo ""
|
||||
@echo "Verification:"
|
||||
@echo " make verify-guidelines - Run comprehensive guidelines check"
|
||||
@echo " make verify-quick - Run quick guidelines check"
|
||||
@echo ""
|
||||
@echo "Release:"
|
||||
@echo " ./release v0.3.0 - Tag, push, and release (notes auto-generated from commits)"
|
||||
@@ -153,14 +151,7 @@ test-env-up:
|
||||
test-env-down:
|
||||
$(COMPOSE) down -v
|
||||
|
||||
# Verify project guidelines compliance
|
||||
verify-guidelines:
|
||||
@echo "Running comprehensive project guidelines verification..."
|
||||
@./scripts/verify-guidelines.sh
|
||||
|
||||
verify-quick:
|
||||
@echo "Running quick project guidelines verification..."
|
||||
@./scripts/verify-quick.sh
|
||||
|
||||
# Create an annotated version tag carrying auto-generated release notes (git-cliff)
|
||||
# and push it. The tag push triggers .gitea/workflows/release.yml, which builds the
|
||||
|
||||
@@ -1,480 +0,0 @@
|
||||
# Combined Project Guidelines for Bookhoard
|
||||
|
||||
## 🚨 CRITICAL PROHIBITIONS (Never violate these)
|
||||
|
||||
### Backend & Database
|
||||
|
||||
- ❌ **NEVER modify backend code when working on frontend-only tasks*
|
||||
- ❌ **NEVER modify database schema** unless explicitly instructed for full-stack changes
|
||||
- ❌ **NEVER use Docker** - use Podman only
|
||||
- ❌ **NEVER build server binaries locally** - all builds through Dockerfile/docker-compose
|
||||
- ❌ **NEVER create new migration files** - merge changes into current one until release
|
||||
- ❌ **NEVER use `git checkout` on schema files** without checking what will be lost
|
||||
- ❌ **NEVER break existing functionality** unless explicitly instructed
|
||||
- ❌ **NEVER duplicate business logic** - keep logic in services, not handlers
|
||||
- ❌ **NEVER bypass service layer** - all database operations must go through services
|
||||
|
||||
### Testing
|
||||
|
||||
- ✅ **ALWAYS use `setupTestServer()` helper from `cmd/server/tests/test_helpers_test.go`*
|
||||
- ✅ **Share one test setup across all subtests** - call `setupTestServer()` once at test function level, not per subtest
|
||||
- ✅ **Prefer table-driven tests** - use `t.Run()` with test cases instead of duplicate test functions
|
||||
- ✅ **Configure database pools efficiently** - use `max_conns=1` for test pools (via `pgxpool.ParseConfig()`) to prevent connection exhaustion
|
||||
- ❌ **NEVER create separate `pgxpool` per test** - each pool creates 4 connections by default; 78 tests = 312 potential connections > PostgreSQL's 100 limit
|
||||
- ❌ **NEVER call `setupTestServer()` in loops or within subtests** - creates unnecessary database pools and exhausts connections
|
||||
- ✅ **DO verify tests pass** - run full test suite before completing work
|
||||
- ✅ **Use `t.Cleanup()` properly** - the `TestServerSetup` pattern automatically handles cleanup via `t.Cleanup()`
|
||||
|
||||
### Frontend & Styling
|
||||
|
||||
- ❌ **NEVER modify backend/API for frontend features without user confirmation*
|
||||
- ❌ **NEVER use custom CSS** - TailwindCSS classes only
|
||||
- **⚠️ EXCEPTION**: `templates/error.templ` may have inline CSS because error pages must work when main app fails (404, server errors, CSS fails to load)
|
||||
- ❌ **NEVER use JavaScript** - convert all to TypeScript
|
||||
- **⚠️ EXCEPTION**: Inline JS function calls in HTML attributes (e.g., `onclick="myFunction()"`) are acceptable for simple interactions
|
||||
- ❌ **NEVER use Object-Oriented Programming** (no classes, inheritance, or this-capture)
|
||||
- ✅ **DO use procedural/imperative style** as your default
|
||||
- ✅ **DO borrow functional techniques** when they simplify code
|
||||
- ✅ **DO avoid ideological purity** - the best paradigm is the one that fits the problem
|
||||
- ❌ **NEVER add new Dockerfiles without user confirmation*
|
||||
- ❌ **NEVER fetch initial data via AJAX on page load** - use server-side rendering instead
|
||||
- ❌ **NEVER break progressive enhancement** - pages must work without JavaScript
|
||||
|
||||
**Note:** Go methods in the backend are fine and encouraged. This guideline applies to TypeScript/JavaScript frontend code only.
|
||||
|
||||
### General
|
||||
|
||||
- ❌ **NEVER skip pre-commit hooks** unless explicitly requested
|
||||
- ❌ **NEVER force push to main/master** branches
|
||||
- ❌ **NEVER commit files with secrets** (.env, credentials.json, etc.)
|
||||
- ❌ **NEVER make assumptions** - ask clarifying questions when uncertain
|
||||
- ❌ **NEVER delete code without reading full context first** (minimum 20 lines before/after)
|
||||
- ❌ **NEVER make cascading fix-up edits without git diff review*
|
||||
- After compilation error: STOP, review `git diff`, understand full impact
|
||||
- Use revert/reapply pattern instead of blind fixes
|
||||
- ❌ **NEVER skip post-edit verification** - must compile after each file edit
|
||||
- ❌ **NEVER run git commands concurrently** - always run sequentially:
|
||||
- Run `git add <files>` and wait for completion
|
||||
- Run `git commit -m "<message>"` and wait for completion
|
||||
- Run `git push` and wait for completion
|
||||
- Never use `&&` to chain git commands together
|
||||
- ✅ **Make good organized git commits for the entire project (not just what you changed) and push - run git add, commit, push sequentially as separate commands, no need to repeatedly check status*
|
||||
- ✅ **Indentation is always 2 spaces unless the language prohibits it*
|
||||
|
||||
### Cascading Fix-up Pattern (PROHIBITED)
|
||||
|
||||
**WHAT NOT TO DO** - This caused critical bugs:
|
||||
|
||||
```go
|
||||
// ❌ WRONG: Blindly making fixes after compilation error
|
||||
|
||||
Edit 1: Delete deprecated code
|
||||
[Compilation error: undefined Register]
|
||||
|
||||
Edit 2: Try to fix error (over-broad deletion)
|
||||
[More errors: undefined Login, GetProfile, etc.]
|
||||
|
||||
Edit 3: Try to fix again (worse damage)
|
||||
[Even more errors: major functionality broken]
|
||||
```
|
||||
|
||||
**CORRECT APPROACH**:
|
||||
|
||||
```go
|
||||
// ✅ CORRECT: Stop, understand, then fix deliberately
|
||||
|
||||
Edit 1: Delete deprecated code
|
||||
[Compilation error: undefined Register]
|
||||
|
||||
STOP → Review git diff → Understand Register was accidentally deleted
|
||||
RESTORE → Get exact Register function from git history
|
||||
VERIFY → Compile successfully
|
||||
```
|
||||
|
||||
**Key Principle**: When compilation errors occur after edits:
|
||||
|
||||
1. STOP - Don't make more edits
|
||||
2. ANALYZE - Use `git diff` to understand what was changed
|
||||
3. RECOVER - Restore what was accidentally deleted/broken
|
||||
4. VERIFY - Compile and test before proceeding
|
||||
|
||||
---
|
||||
|
||||
## 🎯 CONTEXT-SPECIFIC RULES
|
||||
|
||||
### When Working on Frontend-Only Tasks
|
||||
|
||||
- **DO NOT touch backend code** - handlers, services, database layer
|
||||
- **DO NOT modify API routes** - use existing endpoints only
|
||||
- **DO NOT change database schema** - work with existing structure
|
||||
- **If backend change seems necessary**:
|
||||
1. Identify the required change
|
||||
2. Explain why you need it
|
||||
3. Provide impact analysis
|
||||
4. **ASK FOR USER CONFIRMATION before proceeding*
|
||||
|
||||
### When Working on Full-Stack Tasks
|
||||
|
||||
- Backend changes are allowed when explicitly part of the task
|
||||
- Still follow all database protocols (atomic changes, validation, etc.)
|
||||
- **If modifying database schema:** Update local database after schema.sql changes (see Database Operations section)
|
||||
- Still use Podman for all builds
|
||||
- Still include Bruno requests for API changes
|
||||
|
||||
---
|
||||
|
||||
## ✅ MANDATORY REQUIREMENTS
|
||||
|
||||
### Database Operations (Full-Stack Tasks Only)
|
||||
|
||||
- ✅ Follow **pgx v5 standards** for all database operations
|
||||
- ✅ Treat schema changes as **ATOMIC** - complete success or complete rejection
|
||||
- ✅ **⚠️ CRITICAL: This is a pre-production application (NO production deployments exist)*
|
||||
- When `database/schema/schema.sql` is updated, local databases must be updated
|
||||
- **Option 1 (Recommended):** Recreate database with fresh schema:
|
||||
```bash
|
||||
podman compose down -v # Delete volumes (WARNING: loses all data)
|
||||
podman compose up -d # Start fresh with new schema
|
||||
```
|
||||
- **Option 2:** Manually apply schema changes to existing database using psql
|
||||
- **DO NOT create migration files** - no legacy schema support needed
|
||||
- ✅ Use **pre-change checklist**: read schema → identify columns → plan changes → verify → read back
|
||||
- ✅ **Post-change validation**: ensure schema.sql, models.go, and queries.sql are in sync
|
||||
|
||||
### Build & Deployment
|
||||
|
||||
- ✅ Use **Podman** exclusively (not Docker)
|
||||
- ✅ All builds through existing **Dockerfile** and **docker-compose.yml*
|
||||
- ✅ Stop building server binaries - everything goes through containers
|
||||
|
||||
### API Changes (Full-Stack Tasks Only)
|
||||
|
||||
- ✅ Include **Bruno OpenCollection YAML requests** with all API documentation
|
||||
- ✅ Integration Tests (cmd/server/tests) must be **comprehensive and cover three contexts**: no user, user, and admin
|
||||
- ✅ Maintain backward compatibility for mobile apps and external consumers
|
||||
|
||||
### Frontend & Styling
|
||||
|
||||
- ✅ Always use **TailwindCSS classes** for all styling
|
||||
- ✅ Convert all JavaScript to **TypeScript**
|
||||
- ✅ **Never use Object-Oriented Programming** (no classes, inheritance, or this-capture)
|
||||
- ✅ **Use procedural/imperative style** as your default
|
||||
- ✅ **Borrow functional techniques** when they simplify code
|
||||
- ✅ **Avoid ideological purity** - the best paradigm is the one that fits the problem
|
||||
- ✅ **Extract domain concepts/types only when clearly beneficial** - apply YAGNI, avoid over-engineering
|
||||
- ✅ **Render initial data server-side** in Go templates for fast page loads
|
||||
- ✅ **Use JavaScript/HTMX for CRUD operations** (create, update, delete)
|
||||
- ✅ **Ensure progressive enhancement** - pages work without JavaScript
|
||||
|
||||
### Service Layer Architecture
|
||||
|
||||
- ✅ **All business logic in services** - never in handlers
|
||||
- ✅ **Services must be reusable** by both SSR handlers and API endpoints
|
||||
- ✅ **Database operations through services only** - never direct from handlers
|
||||
- ✅ **When adding features**: Add service logic → Create API endpoint → Use SSR for initial render → Use JS for updates
|
||||
|
||||
### Code Organization
|
||||
|
||||
- ✅ Minimize project structure changes
|
||||
- ✅ Place new files in **contextually appropriate directories*
|
||||
- ✅ Follow **KISS**, **DRY**, and **YAGNI** principles
|
||||
- ✅ Use **multiple, logical git commits** with clear messages
|
||||
- ❌ **NEVER duplicate types between handlers and templates** - define data types in handlers, reuse directly in templates
|
||||
- ✅ **DO share handler types with templates** - templates should use handlers.CollectionData, handlers.BookInfo, etc. directly
|
||||
- ❌ **NEVER create parallel type systems** - no templates.XxxData types for data that originates from handlers
|
||||
- ✅ **DO enhance handler types with template-specific fields** when needed (e.g., add Icon, FormattedDate fields to handlers structs)
|
||||
- ✅ **DO keep template-only types in templates package** - PageData, UnsafeHTML, and template-specific utilities are appropriate
|
||||
- ❌ **NEVER create conversion helper functions** to map between handler and template types - use handler types directly
|
||||
|
||||
### Configuration & Environment
|
||||
|
||||
- ✅ If **.env is missing**, auto-generate secure values
|
||||
- ✅ Never commit secrets to repository
|
||||
|
||||
### Code Modification Safety
|
||||
|
||||
- ✅ **Post-Edit Verification (MANDATORY for ALL file modifications)**:
|
||||
- Run `go build` for affected packages immediately after each edit
|
||||
- Review `git diff filename` to verify only intended changes
|
||||
- Validate functionality still works as expected
|
||||
- Never proceed to next file until current edit is verified
|
||||
- ✅ **Backup Before Large Changes**:
|
||||
- Create a stash: `git stash push -m "Pre-cleanup snapshot"` before removing >50 lines
|
||||
- Or create a backup branch: `git branch backup-before-cleanup`
|
||||
- This allows instant recovery if mistakes occur
|
||||
- ✅ **Large Deletion Safety Pattern**:
|
||||
- Read at least 20 lines before/after deletion target
|
||||
- Include unique identifiers in match (function signatures, specific comments)
|
||||
- Use narrow matches - avoid generic patterns
|
||||
- Verify line numbers match intended section
|
||||
|
||||
### Documentation
|
||||
|
||||
**Documentation Structure** (updated with full docs system):
|
||||
|
||||
- ✅ **README.md** - Project overview, quick start, and setup instructions only
|
||||
- ✅ **docs/** - Comprehensive documentation system with search
|
||||
- ✅ **docs/developer/api/** - API reference documentation (split by endpoint/category)
|
||||
- ✅ **docs/user/** - User-facing features, guides, and workflows
|
||||
- ✅ **docs/user/devices/** - Device setup guides (KOBO, KOReader, etc.)
|
||||
- ✅ **docs/contributing/** - Development and contribution guides
|
||||
|
||||
**Where to document changes**:
|
||||
|
||||
| Change Type | Location | Examples |
|
||||
| ----------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| **User-facing features** | `docs/user/` | New features, UI changes, workflows |
|
||||
| **API endpoints** | `docs/developer/api/<category>/<endpoint>.md` | New endpoints, modified responses, authentication changes |
|
||||
| **API behavior** | Update existing `docs/developer/api/` files | Parameter changes, error codes, rate limits |
|
||||
| **Device setup** | `docs/user/devices/` | New device support, setup instructions |
|
||||
| **Development** | `docs/contributing/` | Build changes, architecture decisions |
|
||||
| **Quick start/setup** | `README.md` | Installation, environment setup, first-run |
|
||||
| **Breaking changes** | Both `README.md` and relevant `docs/` | Migration guides, deprecation notices |
|
||||
| **Bug fixes** | Update relevant `docs/` only if user-visible | Clarifications, troubleshooting additions |
|
||||
| **Bruno OpenCollection YAML requests** | `.yml` files in bruno folder in appropriate folder/sub-folder | API contract testing, examples |
|
||||
|
||||
**Documentation Update Workflow**:
|
||||
|
||||
1. **Identify the audience** (end users, developers, API consumers)
|
||||
2. **Choose appropriate location** based on table above
|
||||
3. **Update documentation** before or with code changes
|
||||
4. **Verify documentation renders** at `/docs` endpoint
|
||||
5. **Test search** finds new/updated content
|
||||
6. **For API changes**: Update both `docs/developer/api/` files, integration test files (cmd/server/tests) AND Bruno OpenCollection YAML `.yml` files
|
||||
7. **Commit separately** with clear message: `docs: <description>`
|
||||
|
||||
**When in doubt**:
|
||||
|
||||
- End-user visible → `docs/user/`
|
||||
- API reference → `docs/developer/api/`
|
||||
- Setup/onboarding → `README.md`
|
||||
- Development related → `docs/contributing/`
|
||||
|
||||
### Process & Continuity
|
||||
|
||||
- ✅ If mid-task and receive "no response", **continue the task*
|
||||
- ✅ Verify no regressions before modifying/removing code
|
||||
|
||||
---
|
||||
|
||||
## 🔧 TECHNICAL STANDARDS
|
||||
|
||||
### Backend Stack
|
||||
|
||||
- **Language**: Go 1.25+
|
||||
- **Database**: PostgreSQL 15+ with **pgx v5 driver** only
|
||||
- **Authentication**: JWT tokens with bcrypt password hashing
|
||||
- **Architecture**: Service layer pattern (handlers → services → database)
|
||||
|
||||
### Frontend Stack
|
||||
|
||||
- **Rendering**: SSR-first - Go templates render initial page with all data
|
||||
- **Interactivity**: Alpine.js for UI state (modals, dropdowns, transitions)
|
||||
- **Dynamic Updates**: HTMX for CRUD operations (no full page reloads)
|
||||
- **Styling**: TailwindCSS (no custom CSS)
|
||||
- **Language**: TypeScript (no JavaScript)
|
||||
- **Patterns**: Procedural/imperative with functional techniques where helpful (no OOP)
|
||||
|
||||
### SSR-First Principles
|
||||
|
||||
- ❌ **NEVER fetch initial data via AJAX** - server renders complete page
|
||||
- ❌ **NEVER fetch data in Alpine x-init** - data already SSR'd
|
||||
- ✅ **Use Alpine.js only for UI state** - modal visibility, dropdown toggles
|
||||
- ✅ **Use HTMX for dynamic operations** - form submissions, partial page updates
|
||||
- ✅ **x-init is for setup only** - event listeners, not data fetching
|
||||
- ✅ **Data fetching happens after user actions** - not on page load
|
||||
|
||||
### Page Type Classifications
|
||||
|
||||
1. **Type 1: 80% SSR (most pages)**
|
||||
- Backend provides all initial data
|
||||
- Alpine handles modals/dropdowns only
|
||||
- x-init NEVER fetches data
|
||||
|
||||
2. **Type 2: SSR + Interactive (dashboard, bookshelf)**
|
||||
- Backend provides initial data
|
||||
- Alpine handles interactivity (drag-drop, filtering)
|
||||
- x-init ONLY sets up event listeners
|
||||
|
||||
3. **Type 3: 80% TypeScript (analytics, complex dashboards)**
|
||||
- Some client-side data fetching acceptable
|
||||
- Still prefer SSR when possible
|
||||
|
||||
### Containerization
|
||||
|
||||
- **Runtime**: Podman (not Docker)
|
||||
- **Build**: Existing Dockerfile and docker-compose.yml only
|
||||
- **No local builds** allowed
|
||||
|
||||
---
|
||||
|
||||
## 🔄 ERROR RECOVERY PROTOCOL
|
||||
|
||||
When code modification mistakes occur (deleted wrong code, broke compilation, etc.):
|
||||
|
||||
### Immediate Actions
|
||||
|
||||
1. **STOP** - Don't make more edits
|
||||
2. **ASSESS** - What was deleted? Is it critical?
|
||||
3. **REVIEW** - Run `git diff` to see exact changes
|
||||
4. **RESTORE** - Choose appropriate recovery method:
|
||||
- **Recent mistake**: `git checkout -- filename`
|
||||
- **Complex restoration**: Use `git show HEAD:filename` to recover deleted code
|
||||
- **Multiple files**: `git reset HEAD~1` (if safe)
|
||||
5. **VERIFY** - Compile and test restored code
|
||||
6. **DOCUMENT** - Note what went wrong for future reference
|
||||
|
||||
### Recovery Examples
|
||||
|
||||
```bash
|
||||
# Recover a deleted function from original file
|
||||
git show HEAD:internal/handlers/auth.go | sed -n '70,275p' > recovery.txt
|
||||
|
||||
# Revert entire file to original state
|
||||
git checkout HEAD -- internal/handlers/auth.go
|
||||
|
||||
# Use git stash to save current state before rollback
|
||||
git stash push -m "Broken state before fix"
|
||||
git checkout -- internal/handlers/auth.go
|
||||
```
|
||||
|
||||
### Prevention (Learn From Mistakes)
|
||||
|
||||
- Why did the mistake happen?
|
||||
- Was it too-broad matching?
|
||||
- Was it insufficient context reading?
|
||||
- Was it cascading fix-up attempts?
|
||||
- Update guidelines to prevent recurrence
|
||||
|
||||
---
|
||||
|
||||
## 📋 WORKFLOW CHECKLISTS
|
||||
|
||||
### Before Making Frontend-Only Changes
|
||||
|
||||
- [ ] Identify if backend modification could make implementation simpler
|
||||
- [ ] Plan to use existing API endpoints only
|
||||
- [ ] If backend change seems necessary, prepare confirmation request:
|
||||
- Required change description
|
||||
- Why it would help
|
||||
- Impact analysis
|
||||
- Alternative approaches considered
|
||||
- [ ] Plan git commit structure (multiple logical commits)
|
||||
- [ ] **Identify documentation location** (see Documentation section):
|
||||
- [ ] User-facing feature → `docs/user/`
|
||||
- [ ] UI/workflow changes → `docs/user/`
|
||||
- [ ] Setup instructions → `README.md`
|
||||
|
||||
### Before Making Full-Stack Changes
|
||||
|
||||
- [ ] Read current schema completely (if database changes)
|
||||
- [ ] Identify all columns that must be preserved
|
||||
- [ ] Plan exact changes needed
|
||||
- [ ] Verify Podman will be used for builds
|
||||
- [ ] Plan git commit structure (multiple logical commits)
|
||||
- [ ] **Identify documentation location**:
|
||||
- [ ] API changes → `docs/developer/api/<category>/`
|
||||
- [ ] New endpoints → Create new `.md` file in `docs/developer/api/`
|
||||
- [ ] API behavior → Update existing `docs/developer/api/` files
|
||||
- [ ] Breaking changes → Both `README.md` + relevant `docs/`
|
||||
- [ ] Bruno OpenCollection YAML `.yml` files → Update/create alongside API changes
|
||||
- [ ] Integration tests (cmd/server/tests) → Update/create alongside API changes
|
||||
|
||||
### During Schema Changes (Full-Stack Only)
|
||||
|
||||
- [ ] Read current schema completely
|
||||
- [ ] Identify all columns that must be preserved
|
||||
- [ ] Plan exact changes needed
|
||||
- [ ] Set up verification step
|
||||
- [ ] Make intended changes
|
||||
- [ ] Immediately verify by reading back modified sections
|
||||
- [ ] Confirm ALL expected columns are present
|
||||
- [ ] Verify schema.sql, models.go, and queries.sql are in sync
|
||||
- [ ] **Update local database** (choose ONE):
|
||||
- [ ] **Option 1 - Recreate database** (recommended, loses data):
|
||||
```bash
|
||||
podman compose down -v # Delete all volumes
|
||||
podman compose up -d # Start with fresh schema
|
||||
```
|
||||
- [ ] **Option 2 - Manual SQL migration** (preserves data):
|
||||
```bash
|
||||
podman exec bookhoard_db psql -U postgres -d bookhoard -c "YOUR SQL HERE"
|
||||
```
|
||||
- [ ] Verify database has new schema (check column types, indexes, etc.)
|
||||
|
||||
### After API Changes
|
||||
|
||||
- [ ] Create/update Bruno OpenCollection YAML requests
|
||||
- [ ] Create/update Integration tests (cmd/server/tests)
|
||||
- [ ] Test with no user context
|
||||
- [ ] Test with regular user context
|
||||
- [ ] Test with admin context
|
||||
- [ ] Verify backward compatibility
|
||||
|
||||
### Before Committing
|
||||
|
||||
- [ ] **Run verification script**: `bash scripts/verify-guidelines.sh`
|
||||
- [ ] **Fix any errors** - verification must pass (0 errors) to commit
|
||||
- [ ] **Note warnings** - informational only, do not auto-fix
|
||||
- [ ] Run tests: `go test ./... -v`
|
||||
- [ ] Run lint/typecheck if available
|
||||
- [ ] Ensure no secrets in changes
|
||||
- [ ] Verify logical commit structure
|
||||
- [ ] **Update documentation** (see Documentation section):
|
||||
- [ ] User-facing changes → `docs/user/`
|
||||
- [ ] API changes → `docs/developer/api/` + Integration tests + Bruno OpenCollection YAML `.yml` files
|
||||
- [ ] Setup/onboarding → `README.md`
|
||||
- [ ] Development changes → `docs/contributing/`
|
||||
- [ ] **Verify docs render** at `/docs` endpoint
|
||||
- [ ] **Test docs search** finds new content
|
||||
|
||||
### Error Recovery Protocol (If Code Mistakes Occur)
|
||||
|
||||
- [ ] **Stop immediately** - don't make more edits
|
||||
- [ ] **Assess impact**: What was deleted? Is it critical?
|
||||
- [ ] **Review git diff**: See exact changes made
|
||||
- [ ] **Restore strategy**:
|
||||
- If recent mistake: `git checkout -- filename`
|
||||
- If complex: Reconstruct from git diff using `git show HEAD:filename`
|
||||
- [ ] **Verify recovery**: Compile and test restored code
|
||||
- [ ] **Document mistake**: Note what went wrong for future reference
|
||||
|
||||
### Phase Completion Verification (Before Declaring "Complete")
|
||||
|
||||
- [ ] All target code is removed/intact as intended
|
||||
- [ ] No unintended code was deleted
|
||||
- [ ] All affected files compile successfully
|
||||
- [ ] Run `go build ./...` for entire project
|
||||
- [ ] **Run verification script**: `bash scripts/verify-guidelines.sh` - must pass (0 errors)
|
||||
- [ ] No critical functionality was broken
|
||||
- [ ] Git diff shows only intended changes
|
||||
- [ ] Review all modified files with `git diff --stat`
|
||||
|
||||
---
|
||||
|
||||
## 🏗 ARCHITECTURAL PATTERNS
|
||||
|
||||
### Current: SSR-First with Alpine.js + HTMX
|
||||
|
||||
```
|
||||
Browser → Go template (SSR with all data) → Display instantly
|
||||
↓
|
||||
Alpine.js for UI state (modals, dropdowns)
|
||||
↓
|
||||
HTMX for CRUD (forms, updates)
|
||||
↓
|
||||
Shared service layer (Go)
|
||||
```
|
||||
|
||||
**Layer Responsibilities:**
|
||||
|
||||
| Layer | Responsibility |
|
||||
|-------|---------------|
|
||||
| **Go Template** | SSR initial page with real data |
|
||||
| **Alpine.js** | UI state only (x-data, x-show, transitions) |
|
||||
| **HTMX** | Dynamic updates without page reload |
|
||||
| **TypeScript** | Pure business logic (API calls, data processing) |
|
||||
|
||||
**See also:** `SSR_FIRST_ALPINE_GUIDE.md` and `ALPINE_COMPLETION_GUIDE.md`
|
||||
|
||||
Ultimately, whenever you are unsure just ask for confirmation.
|
||||
@@ -127,6 +127,32 @@ The first user to register automatically becomes an admin.
|
||||
|
||||
---
|
||||
|
||||
## 💾 Network Storage (NAS / NFS / SMB)
|
||||
|
||||
Libraries on network mounts are supported for everyday use — serving,
|
||||
reading, progress, and annotation sync all work over the mount — with a
|
||||
few behaviors worth knowing:
|
||||
|
||||
- **Outage hysteresis.** When the mount drops, files miss one scan
|
||||
(marked missing, hidden) then a second (archived, still recoverable).
|
||||
When the mount returns — by path or by identical content elsewhere —
|
||||
everything is restored automatically: annotations, progress, and
|
||||
covers included.
|
||||
- **Retention purge.** Archived items older than `ARCHIVE_RETENTION_DAYS`
|
||||
(default **90**, `0` = never auto-purge) are deleted for good by
|
||||
library scans, annotations included. A NAS offline longer than the
|
||||
retention window loses those books' metadata on return.
|
||||
- **Deleting while the storage is down is refused.** Deleting a book
|
||||
cascades its annotations, so deletes are rejected with a clear error
|
||||
while the item's library storage is unreachable. Archive the item
|
||||
instead, or retry when the mount is back.
|
||||
- **Watch mode does not fire on network mounts** (inotify doesn't work
|
||||
over NFS/SMB). The periodic scan is the change detector.
|
||||
- **Latency** — folder reachability checks are cached ~30s; large
|
||||
libraries on slow mounts will still feel slower than local disk.
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Tech Stack
|
||||
|
||||
- **Backend**: Go 1.25+ with Echo framework
|
||||
|
||||
@@ -155,6 +155,8 @@ func main() {
|
||||
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
||||
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
||||
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
||||
statisticsService := services.NewStatisticsService(queries)
|
||||
statsHandler := handlers.NewStatsHandler(statisticsService)
|
||||
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
||||
|
||||
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
|
||||
@@ -226,6 +228,8 @@ func main() {
|
||||
WSHandler: wsHandler,
|
||||
ConflictHandler: conflictHandler,
|
||||
AnalyticsHandler: analyticsHandler,
|
||||
StatsHandler: statsHandler,
|
||||
StatisticsService: statisticsService,
|
||||
QueueHandler: queueHandler,
|
||||
CollectionHandler: collectionHandler,
|
||||
FiltersHandler: filtersHandler,
|
||||
|
||||
@@ -93,8 +93,12 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("GetReadingStats_EmptyHistory", func(t *testing.T) {
|
||||
// The REGULAR user, deliberately: the shared dev DB carries a
|
||||
// live reader on `testuser` whose real sessions accrue even
|
||||
// while the suite runs, so "empty history" only holds for a
|
||||
// freshly-created account.
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
@@ -351,8 +355,11 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("GetPopularBooks_NoReadingHistory", func(t *testing.T) {
|
||||
// The REGULAR user, deliberately: the live dev-reader's sessions
|
||||
// accrue on `testuser` while the suite runs, so an empty-history
|
||||
// assertion only holds for a fresh account.
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/popular-books", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
)
|
||||
|
||||
// HTTP helpers -----------------------------------------------------------
|
||||
|
||||
func smartCreateCollection(t *testing.T, setup *TestServerSetup, token, name string, collectionType string) string {
|
||||
t.Helper()
|
||||
payload := map[string]interface{}{
|
||||
"name": name,
|
||||
"description": "smart collections test",
|
||||
"color": "blue",
|
||||
"icon": "📚",
|
||||
"collection_type": collectionType,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, "collection creation should succeed")
|
||||
|
||||
var result map[string]interface{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
||||
return result["id"].(string)
|
||||
}
|
||||
|
||||
func smartGetCollection(t *testing.T, setup *TestServerSetup, token, collectionID string) map[string]interface{} {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections/"+collectionID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var result map[string]interface{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
||||
return result
|
||||
}
|
||||
|
||||
func smartPostRule(t *testing.T, setup *TestServerSetup, token, collectionID, field, operator, value string, enabled *bool) *http.Response {
|
||||
t.Helper()
|
||||
payload := map[string]interface{}{
|
||||
"field": field,
|
||||
"operator": operator,
|
||||
"value": value,
|
||||
}
|
||||
if enabled != nil {
|
||||
payload["enabled"] = *enabled
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collectionID+"/rules", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
return resp
|
||||
}
|
||||
|
||||
func smartDecodeRuleID(t *testing.T, resp *http.Response) string {
|
||||
t.Helper()
|
||||
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
||||
var result map[string]interface{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
||||
rule := result["rule"].(map[string]interface{})
|
||||
return rule["id"].(string)
|
||||
}
|
||||
|
||||
// Data helpers -----------------------------------------------------------
|
||||
|
||||
func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uuid.UUID, titlesByGenre map[string]string) (libraryID string, bookIDs []string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
|
||||
require.NoError(t, err)
|
||||
uniqueName := fmt.Sprintf("Test Smart Rules Library %d", time.Now().UnixNano())
|
||||
library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||
Name: uniqueName,
|
||||
Description: pgtype.Text{String: "smart collections test library", Valid: true},
|
||||
LibraryTypeID: libType.ID,
|
||||
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
||||
})
|
||||
require.NoError(t, err, "should create test library")
|
||||
libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = setup.DB.DeleteLibrary(ctx, library.ID)
|
||||
})
|
||||
|
||||
for title, genre := range titlesByGenre {
|
||||
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
Title: title,
|
||||
Author: pgtype.Text{String: "Smart Rule Author", Valid: true},
|
||||
Genre: pgtype.Text{String: genre, Valid: true},
|
||||
FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
})
|
||||
require.NoError(t, err, "should create test media item")
|
||||
mediaUUID, err := uuid.FromBytes(media.ID.Bytes[0:16])
|
||||
require.NoError(t, err)
|
||||
bookIDs = append(bookIDs, mediaUUID.String())
|
||||
}
|
||||
return libUUID.String(), bookIDs
|
||||
}
|
||||
|
||||
func smartLibraryTitleSet(t *testing.T, coll map[string]interface{}) map[string]bool {
|
||||
t.Helper()
|
||||
books, _ := coll["books"].([]interface{})
|
||||
set := make(map[string]bool, len(books))
|
||||
for _, b := range books {
|
||||
if m, ok := b.(map[string]interface{}); ok {
|
||||
if title, ok := m["title"].(string); ok {
|
||||
set[title] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// Tests ------------------------------------------------------------------
|
||||
|
||||
func TestSmartCollections(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
adminID := getTestUserID(t, setup.DB)
|
||||
|
||||
t.Run("FirstRuleConvertsCollectionToSmart_LiveMembership", func(t *testing.T) {
|
||||
_, bookIDs := smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
||||
"Smart SF Book One": "Science Fiction",
|
||||
"Smart SF Book Two": "Science Fiction",
|
||||
"Smart FANT Book": "Fantasy",
|
||||
})
|
||||
|
||||
// Rule evaluation ignores stored links: a static collection's own
|
||||
// added item must NOT show up once the collection is smart.
|
||||
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Live "+uuid.NewString()[:8], "static")
|
||||
|
||||
// Add one hand-picked book (a Fantasy one that the smart rule
|
||||
// will not match — so it must vanish from the render).
|
||||
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{bookIDs[2]}})
|
||||
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
|
||||
addReq.Header.Set("Content-Type", "application/json")
|
||||
addReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
addResp, err := http.DefaultClient.Do(addReq)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusNoContent, addResp.StatusCode)
|
||||
_ = addResp.Body.Close()
|
||||
|
||||
staticDetail := smartGetCollection(t, setup, setup.Token, collID)
|
||||
assert.Equal(t, false, staticDetail["is_smart"])
|
||||
assert.Equal(t, float64(1), staticDetail["book_count"], "static collection shows its stored link")
|
||||
|
||||
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
smartDetail := smartGetCollection(t, setup, setup.Token, collID)
|
||||
assert.Equal(t, true, smartDetail["is_smart"], "rules presence derives the type")
|
||||
assert.Equal(t, float64(2), smartDetail["book_count"], "live rule matches only")
|
||||
|
||||
memberTitles := smartLibraryTitleSet(t, smartDetail)
|
||||
assert.True(t, memberTitles["Smart SF Book One"])
|
||||
assert.True(t, memberTitles["Smart SF Book Two"])
|
||||
assert.False(t, memberTitles["Smart FANT Book"], "hand-picked link is ignored under smart evaluation (v1 no hybrid)")
|
||||
})
|
||||
|
||||
t.Run("DisabledRuleExcludedFromLiveSet", func(t *testing.T) {
|
||||
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
||||
"Smart Diss Book": "Science Fiction",
|
||||
})
|
||||
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Disabled "+uuid.NewString()[:8], "smart")
|
||||
|
||||
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
ruleID := smartDecodeRuleID(t, resp)
|
||||
|
||||
assert.Equal(t, float64(1), smartGetCollection(t, setup, setup.Token, collID)["book_count"])
|
||||
|
||||
// Disable: membership drops to zero at once.
|
||||
falseFlag := false
|
||||
dBody, _ := json.Marshal(map[string]interface{}{"enabled": falseFlag})
|
||||
dReq, _ := http.NewRequest("PUT", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, bytes.NewBuffer(dBody))
|
||||
dReq.Header.Set("Content-Type", "application/json")
|
||||
dReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
dResp, err := http.DefaultClient.Do(dReq)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, dResp.StatusCode)
|
||||
_ = dResp.Body.Close()
|
||||
|
||||
assert.Equal(t, float64(0), smartGetCollection(t, setup, setup.Token, collID)["book_count"], "disabled rules never match")
|
||||
})
|
||||
|
||||
t.Run("SmartCollectionsRejectMembershipMutations", func(t *testing.T) {
|
||||
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
||||
"Smart Guard Book": "Science Fiction",
|
||||
})
|
||||
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Guard "+uuid.NewString()[:8], "smart")
|
||||
|
||||
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}})
|
||||
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
|
||||
addReq.Header.Set("Content-Type", "application/json")
|
||||
addReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
addResp, err := http.DefaultClient.Do(addReq)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, addResp.StatusCode)
|
||||
_ = addResp.Body.Close()
|
||||
|
||||
removeReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID+"/books/"+uuid.NewString(), nil)
|
||||
removeReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
removeResp, err := http.DefaultClient.Do(removeReq)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, removeResp.StatusCode)
|
||||
_ = removeResp.Body.Close()
|
||||
})
|
||||
|
||||
t.Run("RemoveLastRuleRevertsToStatic", func(t *testing.T) {
|
||||
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
||||
"Smart Back Book": "Science Fiction",
|
||||
})
|
||||
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Revert "+uuid.NewString()[:8], "smart")
|
||||
|
||||
resp := smartPostRule(t, setup, setup.Token, collID, "tags", "contains", "no-such-tag", nil)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
ruleID := smartDecodeRuleID(t, resp)
|
||||
|
||||
smartDetail := smartGetCollection(t, setup, setup.Token, collID)
|
||||
assert.Equal(t, true, smartDetail["is_smart"])
|
||||
|
||||
delReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, nil)
|
||||
delReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
delResp, err := http.DefaultClient.Do(delReq)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, delResp.StatusCode)
|
||||
_ = delResp.Body.Close()
|
||||
|
||||
staticDetail := smartGetCollection(t, setup, setup.Token, collID)
|
||||
assert.Equal(t, false, staticDetail["is_smart"], "removing the last rule reverts to static")
|
||||
|
||||
// Membership mutations work again on the reverted static.
|
||||
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}})
|
||||
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
|
||||
addReq.Header.Set("Content-Type", "application/json")
|
||||
addReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
addResp, err := http.DefaultClient.Do(addReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusNoContent, addResp.StatusCode)
|
||||
_ = addResp.Body.Close()
|
||||
})
|
||||
|
||||
t.Run("RuleValidationAndSystemCollections", func(t *testing.T) {
|
||||
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Valid "+uuid.NewString()[:8], "static")
|
||||
|
||||
resp := smartPostRule(t, setup, setup.Token, collID, "not_a_field", "equals", "x", nil)
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
resp = smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// System collections may never carry rules.
|
||||
colls, err := setup.DB.GetCollectionsByUser(context.Background(), pgtype.UUID{Bytes: [16]byte(adminID), Valid: true})
|
||||
require.NoError(t, err)
|
||||
var systemID string
|
||||
for _, c := range colls {
|
||||
if c.IsSystemCollection.Bool {
|
||||
systemID = uuid.UUID(c.ID.Bytes).String()
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, systemID, "admin user should have system collections")
|
||||
|
||||
sysResp := smartPostRule(t, setup, setup.Token, systemID, "genre", "equals", "x", nil)
|
||||
require.Equal(t, http.StatusBadRequest, sysResp.StatusCode)
|
||||
_ = sysResp.Body.Close()
|
||||
})
|
||||
|
||||
t.Run("CollectionsListRespectsIncludeAutoWithLiveCounts", func(t *testing.T) {
|
||||
_, _ = smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
||||
"Smart List Book": "Science Fiction",
|
||||
})
|
||||
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart List "+uuid.NewString()[:8], "smart")
|
||||
|
||||
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Default list (the Android app's read): smart collections hidden.
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
defaultResp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) { _ = Body.Close() }(defaultResp.Body)
|
||||
var defaultData struct {
|
||||
Collections []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"collections"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(defaultResp.Body).Decode(&defaultData))
|
||||
for _, c := range defaultData.Collections {
|
||||
assert.NotEqual(t, collID, c.ID, "default listing excludes rule-driven collections (app compat)")
|
||||
}
|
||||
|
||||
// Opt-in listing includes them with LIVE counts.
|
||||
scopeReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections?include_auto=true", nil)
|
||||
scopeReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
scopeResp, err := http.DefaultClient.Do(scopeReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) { _ = Body.Close() }(scopeResp.Body)
|
||||
var scopeData struct {
|
||||
Collections []struct {
|
||||
ID string `json:"id"`
|
||||
BookCount int `json:"book_count"`
|
||||
IsSmart bool `json:"is_smart"`
|
||||
} `json:"collections"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(scopeResp.Body).Decode(&scopeData))
|
||||
found := false
|
||||
for _, c := range scopeData.Collections {
|
||||
if c.ID == collID {
|
||||
found = true
|
||||
assert.True(t, c.IsSmart)
|
||||
assert.Equal(t, 1, c.BookCount, "smart card counts rule matches live")
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "include_auto=true must surface smart collections")
|
||||
})
|
||||
}
|
||||
@@ -140,7 +140,6 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
body := readBody(resp)
|
||||
|
||||
// Verify reading direction badge is displayed
|
||||
assert.Contains(t, body, "📖", "Should display reading direction icon")
|
||||
assert.Contains(t, body, "RTL", "Should display RTL reading direction")
|
||||
})
|
||||
|
||||
@@ -263,7 +262,8 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
|
||||
assert.Contains(t, body, "📚", "Should display story arc icon")
|
||||
// The redesigned badge renders the arc NAME in a text badge
|
||||
// (no 📚 icon) — assert the name.
|
||||
assert.Contains(t, body, "The Dark Phoenix Saga", "Should display story arc name")
|
||||
})
|
||||
|
||||
@@ -508,7 +508,8 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
|
||||
// Verify all manga-specific fields are displayed
|
||||
// The redesigned badge (07578e0) renders the direction text chip
|
||||
// without the 📖 emoji — assert the chip text.
|
||||
assert.Contains(t, body, "RTL", "Should display RTL reading direction")
|
||||
assert.Contains(t, body, "Teen", "Should display age rating")
|
||||
assert.Contains(t, body, "8.5 / 10", "Should display community rating")
|
||||
@@ -619,9 +620,11 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
assert.Contains(t, body, "Everyone", "Should display age rating")
|
||||
assert.Contains(t, body, "goodreads.com", "Should display web URL")
|
||||
|
||||
// Verify comic-specific fields are NOT displayed
|
||||
assert.NotContains(t, body, "Manga Type", "Should not display manga type for ebook")
|
||||
assert.NotContains(t, body, "Scan Info", "Should not display scan info for ebook")
|
||||
// Verify comic-specific fields are NOT displayed. The page body
|
||||
// always contains the admin edit modal's "Manga Type"/"Scan Info"
|
||||
// LABELS — assert the metadata VALUES (what the display block
|
||||
// renders) are absent instead.
|
||||
assert.NotContains(t, body, "yes and right to left", "Should not display manga type value for ebook")
|
||||
assert.NotContains(t, body, "B&W", "Should not display B&W badge for ebook")
|
||||
})
|
||||
|
||||
@@ -682,9 +685,11 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
|
||||
// Page should still render successfully
|
||||
assert.Contains(t, body, "Minimal Comic", "Should display title")
|
||||
// Use "Community Rating:" with colon to avoid matching the HTML comment
|
||||
// The admin edit modal always carries "Manga Type" as a form
|
||||
// LABEL — the display block would render the capitalized VALUE,
|
||||
// which is absent here.
|
||||
assert.NotContains(t, body, "Community Rating:", "Should not show community rating when not set")
|
||||
assert.NotContains(t, body, "Manga Type", "Should not show manga type when unknown")
|
||||
assert.NotContains(t, body, "yes and right to left", "Should not show a manga type value when unknown")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -69,15 +69,16 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
|
||||
assert.IsType(s.T(), float64(0), section["priority"], "priority should be number")
|
||||
}
|
||||
|
||||
// Verify all system collections exist
|
||||
assert.Contains(s.T(), sectionMap, "continue-reading")
|
||||
assert.Contains(s.T(), sectionMap, "recently-added")
|
||||
assert.Contains(s.T(), sectionMap, "recently-read")
|
||||
assert.Contains(s.T(), sectionMap, "not-started")
|
||||
assert.Contains(s.T(), sectionMap, "continue-series")
|
||||
// Verify all system collections exist (names are the section ids;
|
||||
// the canonical seed names are Title-case).
|
||||
assert.Contains(s.T(), sectionMap, "Continue Reading")
|
||||
assert.Contains(s.T(), sectionMap, "Recently Added")
|
||||
assert.Contains(s.T(), sectionMap, "Recently Read")
|
||||
assert.Contains(s.T(), sectionMap, "Not Started")
|
||||
assert.Contains(s.T(), sectionMap, "Continue Series")
|
||||
|
||||
// Verify continue-reading is a system collection
|
||||
continueReading := sectionMap["continue-reading"]
|
||||
continueReading := sectionMap["Continue Reading"]
|
||||
assert.True(s.T(), continueReading["is_system"].(bool), "continue-reading should be system collection")
|
||||
assert.Equal(s.T(), "📖", continueReading["icon"], "icon should match")
|
||||
}
|
||||
@@ -85,6 +86,8 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
|
||||
func (s *DashboardIntegrationTestSuite) TestGetSections_MissingLibraryID() {
|
||||
token := s.setup.Token
|
||||
|
||||
// No library_id = the All Libraries view: the endpoint aggregates
|
||||
// sections across every library (200), it does not 400.
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/sections", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
@@ -95,7 +98,14 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_MissingLibraryID() {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(s.T(), err)
|
||||
sections, ok := response["sections"].([]interface{})
|
||||
require.True(s.T(), ok, "sections should be an array")
|
||||
assert.NotEmpty(s.T(), sections, "All Libraries view should aggregate sections")
|
||||
}
|
||||
|
||||
func (s *DashboardIntegrationTestSuite) TestGetSections_InvalidLibraryID() {
|
||||
@@ -258,6 +268,263 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_ValidNames()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_GlobalScope() {
|
||||
token := s.setup.Token
|
||||
|
||||
// Absent library_id (JSON null after omit) = the global All Libraries
|
||||
// scope: stored in user_dashboard_preferences_global, one row per user.
|
||||
reqBody := map[string]interface{}{
|
||||
"hidden_collections": []string{"not-started"},
|
||||
"collection_order": []string{"continue-reading", "recently-added"},
|
||||
"items_per_section": 15,
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req, _ := http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Second global save with different values: must UPDATE the same row
|
||||
// (the old ON CONFLICT (user_id, library_id) never fired for NULLs and
|
||||
// would have minted a duplicate per save).
|
||||
reqBody["items_per_section"] = 25
|
||||
body, _ = json.Marshal(reqBody)
|
||||
req, _ = http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Reading prefs without library_id returns the global row (not some
|
||||
// arbitrary library's).
|
||||
req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/preferences", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
var prefs map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&prefs)
|
||||
require.NoError(s.T(), err)
|
||||
assert.Equal(s.T(), float64(25), prefs["items_per_section"], "global row should carry the second save")
|
||||
}
|
||||
|
||||
func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_EmptyLibraryIDRejected() {
|
||||
token := s.setup.Token
|
||||
|
||||
// Empty string is a LOST value (a client bug), never a scope — must
|
||||
// stay a loud 400 rather than silently writing the global scope.
|
||||
reqBody := map[string]interface{}{
|
||||
"library_id": "",
|
||||
"hidden_collections": []string{},
|
||||
"collection_order": []string{},
|
||||
"items_per_section": 20,
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req, _ := http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func (s *DashboardIntegrationTestSuite) TestGetPreferences_GlobalAndPerLibraryIndependent() {
|
||||
token := s.setup.Token
|
||||
libraryID := createTestLibraryWithFolder(s.T(), s.setup.Server, token, "Prefs Scope Test Library", false)
|
||||
|
||||
// Global scope: 15.
|
||||
globalBody, _ := json.Marshal(map[string]interface{}{
|
||||
"hidden_collections": []string{},
|
||||
"collection_order": []string{},
|
||||
"items_per_section": 15,
|
||||
})
|
||||
req, _ := http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(globalBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
resp.Body.Close()
|
||||
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Per-library scope: 30. Must not bleed into global or vice versa.
|
||||
libBody, _ := json.Marshal(map[string]interface{}{
|
||||
"library_id": libraryID,
|
||||
"hidden_collections": []string{},
|
||||
"collection_order": []string{},
|
||||
"items_per_section": 30,
|
||||
})
|
||||
req, _ = http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(libBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
resp.Body.Close()
|
||||
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Per-library read.
|
||||
req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/preferences?library_id="+libraryID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
var libPrefs map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&libPrefs)
|
||||
require.NoError(s.T(), err)
|
||||
assert.Equal(s.T(), float64(30), libPrefs["items_per_section"], "per-library scope should read its own row")
|
||||
|
||||
// Global read.
|
||||
req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/preferences", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
var globalPrefs map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&globalPrefs)
|
||||
require.NoError(s.T(), err)
|
||||
assert.Equal(s.T(), float64(15), globalPrefs["items_per_section"], "global scope should stay independent")
|
||||
}
|
||||
|
||||
// TestDeleteSystemCollection_TombstonesAndRestores pins the system-
|
||||
// collection permanence contract: DELETE hides the section everywhere
|
||||
// (tombstone) and restore brings back the EXACT SAME row (id stable),
|
||||
// never a fresh one.
|
||||
func newHTTPClient() *http.Client { return &http.Client{} }
|
||||
func (s *DashboardIntegrationTestSuite) TestDeleteSystemCollection_TombstonesAndRestores() {
|
||||
token := s.setup.Token
|
||||
libraryID := createTestLibraryWithFolder(s.T(), s.setup.Server, token, "Test Library", false)
|
||||
|
||||
collectionID := func(sectionID string) string {
|
||||
url := fmt.Sprintf("%s/api/dashboard/sections?library_id=%s", s.setup.Server.URL, libraryID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := newHTTPClient().Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
||||
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
var response map[string]interface{}
|
||||
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&response))
|
||||
for _, sec := range response["sections"].([]interface{}) {
|
||||
section := sec.(map[string]interface{})
|
||||
if section["id"] == sectionID {
|
||||
return section["collection_id"].(string)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
hasSection := func(sectionID string) bool {
|
||||
url := fmt.Sprintf("%s/api/dashboard/sections?library_id=%s", s.setup.Server.URL, libraryID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := newHTTPClient().Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
||||
var response map[string]interface{}
|
||||
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&response))
|
||||
for _, sec := range response["sections"].([]interface{}) {
|
||||
if sec.(map[string]interface{})["id"] == sectionID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
idBefore := collectionID("Continue Reading")
|
||||
require.NotEmpty(s.T(), idBefore, "Continue Reading should exist")
|
||||
|
||||
// DELETE a system collection = tombstone (204, behaves like a delete).
|
||||
delReq, _ := http.NewRequest("DELETE", s.setup.Server.URL+"/api/collections/"+idBefore, nil)
|
||||
delReq.Header.Set("Authorization", "Bearer "+token)
|
||||
delResp, err := newHTTPClient().Do(delReq)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) { _ = Body.Close() }(delResp.Body)
|
||||
assert.Equal(s.T(), http.StatusNoContent, delResp.StatusCode)
|
||||
|
||||
// Invisible on the dashboard.
|
||||
assert.False(s.T(), hasSection("Continue Reading"), "tombstoned section must vanish")
|
||||
|
||||
// Restore brings back the EXACT SAME row.
|
||||
restoreBody, _ := json.Marshal(map[string]string{"collection_name": "Continue Reading"})
|
||||
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/dashboard/restore-system-collection", bytes.NewBuffer(restoreBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
restoreResp, err := newHTTPClient().Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) { _ = Body.Close() }(restoreResp.Body)
|
||||
require.Equal(s.T(), http.StatusOK, restoreResp.StatusCode)
|
||||
|
||||
idAfter := collectionID("Continue Reading")
|
||||
assert.Equal(s.T(), idBefore, idAfter, "restore must resurrect the SAME row id")
|
||||
assert.True(s.T(), hasSection("Continue Reading"))
|
||||
}
|
||||
|
||||
// TestDeleteUserCollection_HardDelete pins the user-collection contract:
|
||||
// user collections hard-delete exactly as before (no tombstone, no restore).
|
||||
func (s *DashboardIntegrationTestSuite) TestDeleteUserCollection_HardDelete() {
|
||||
token := s.setup.Token
|
||||
|
||||
createBody, _ := json.Marshal(map[string]interface{}{"name": "Test User Shelf", "type": "ebooks"})
|
||||
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/collections", bytes.NewBuffer(createBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
createResp, err := newHTTPClient().Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) { _ = Body.Close() }(createResp.Body)
|
||||
require.Equal(s.T(), http.StatusCreated, createResp.StatusCode)
|
||||
|
||||
var created map[string]interface{}
|
||||
require.NoError(s.T(), json.NewDecoder(createResp.Body).Decode(&created))
|
||||
collectionID := created["id"].(string)
|
||||
|
||||
delReq, _ := http.NewRequest("DELETE", s.setup.Server.URL+"/api/collections/"+collectionID, nil)
|
||||
delReq.Header.Set("Authorization", "Bearer "+token)
|
||||
delResp, err := newHTTPClient().Do(delReq)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) { _ = Body.Close() }(delResp.Body)
|
||||
assert.Equal(s.T(), http.StatusNoContent, delResp.StatusCode)
|
||||
|
||||
listReq, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/collections", nil)
|
||||
listReq.Header.Set("Authorization", "Bearer "+token)
|
||||
listResp, err := newHTTPClient().Do(listReq)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) { _ = Body.Close() }(listResp.Body)
|
||||
var list struct {
|
||||
Collections []map[string]interface{} `json:"collections"`
|
||||
}
|
||||
require.NoError(s.T(), json.NewDecoder(listResp.Body).Decode(&list))
|
||||
for _, c := range list.Collections {
|
||||
assert.NotEqual(s.T(), collectionID, c["id"], "hard-deleted user collection must be gone")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardIntegrationTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(DashboardIntegrationTestSuite))
|
||||
}
|
||||
@@ -336,7 +336,7 @@ func createTestUserForMaxDevices(t *testing.T, ts *httptest.Server, adminToken s
|
||||
}
|
||||
|
||||
jsonData, _ := json.Marshal(createUserPayload)
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/auth/register", bytes.NewBuffer(jsonData))
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/auth/users", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
|
||||
@@ -416,14 +416,19 @@ func createTestUserForMaxDevices(t *testing.T, ts *httptest.Server, adminToken s
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check if user creation was successful
|
||||
if result["user"] == nil {
|
||||
// Admin create returns the user object at the top level; the legacy
|
||||
// register endpoint nested it under "user". Accept both.
|
||||
userObj, ok := result["user"].(map[string]interface{})
|
||||
if !ok {
|
||||
userObj = result
|
||||
}
|
||||
if userObj["id"] == nil {
|
||||
// User creation failed for another reason
|
||||
t.Logf("User creation failed, response: %+v", result)
|
||||
return uuid.UUID{}
|
||||
}
|
||||
|
||||
userIDStr := result["user"].(map[string]interface{})["id"].(string)
|
||||
userIDStr, _ := userObj["id"].(string)
|
||||
userID, _ := uuid.Parse(userIDStr)
|
||||
|
||||
return userID
|
||||
|
||||
@@ -512,7 +512,7 @@ func TestRegenerateDeviceToken_Forbidden(t *testing.T) {
|
||||
defer setup.Server.Close()
|
||||
|
||||
// Create device for user 1
|
||||
device1 := setup.CreateDevice(t, "User1 Device", "koreader", "user1-device")
|
||||
device1 := setup.CreateDevice(t, "User1 Device", "koreader", "user1-test-device")
|
||||
|
||||
// Create a second user with different credentials
|
||||
ctx := context.Background()
|
||||
@@ -524,6 +524,7 @@ func TestRegenerateDeviceToken_Forbidden(t *testing.T) {
|
||||
FirstName: pgtype.Text{String: "Different", Valid: true},
|
||||
LastName: pgtype.Text{String: "User", Valid: true},
|
||||
Role: "user",
|
||||
Timezone: pgtype.Text{String: "UTC", Valid: true},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
t.Run("POST /api/saved-filters creates filter", func(t *testing.T) {
|
||||
reqBody := map[string]interface{}{
|
||||
"name": "My Sci-Fi Books",
|
||||
"name": "My Sci-Fi Test Books",
|
||||
"resource_type": "media-items",
|
||||
"filters": map[string]string{
|
||||
"genre_filter": "Science Fiction",
|
||||
@@ -71,7 +71,7 @@ func TestSavedFilters(t *testing.T) {
|
||||
var filter map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&filter)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "My Sci-Fi Books", filter["name"])
|
||||
assert.Equal(t, "My Sci-Fi Test Books", filter["name"])
|
||||
assert.Equal(t, "media-items", filter["resource_type"])
|
||||
assert.NotEmpty(t, filter["id"])
|
||||
// Filters field will be JSON object (JSONB as raw bytes)
|
||||
@@ -113,7 +113,7 @@ func TestSavedFilters(t *testing.T) {
|
||||
t.Run("PUT /api/saved-filters/:id updates filter", func(t *testing.T) {
|
||||
// Create filter first
|
||||
createReq := map[string]interface{}{
|
||||
"name": "Original Name",
|
||||
"name": "Original Test Name",
|
||||
"resource_type": "media-items",
|
||||
"filters": map[string]string{"search": "original"},
|
||||
}
|
||||
@@ -134,11 +134,13 @@ func TestSavedFilters(t *testing.T) {
|
||||
var createdFilter map[string]interface{}
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
require.NoError(t, err)
|
||||
filterID := createdFilter["id"].(string)
|
||||
filterIDRaw, ok := createdFilter["id"]
|
||||
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
|
||||
filterID := filterIDRaw.(string)
|
||||
|
||||
// Update filter
|
||||
updateReq := map[string]interface{}{
|
||||
"name": "Updated Name",
|
||||
"name": "Updated Test Name",
|
||||
"resource_type": "media-items",
|
||||
"filters": map[string]string{"search": "updated"},
|
||||
}
|
||||
@@ -159,7 +161,7 @@ func TestSavedFilters(t *testing.T) {
|
||||
var updatedFilter map[string]interface{}
|
||||
err = json.NewDecoder(updateResp.Body).Decode(&updatedFilter)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Updated Name", updatedFilter["name"])
|
||||
assert.Equal(t, "Updated Test Name", updatedFilter["name"])
|
||||
})
|
||||
|
||||
t.Run("DELETE /api/saved-filters/:id deletes filter", func(t *testing.T) {
|
||||
@@ -184,7 +186,9 @@ func TestSavedFilters(t *testing.T) {
|
||||
var createdFilter map[string]interface{}
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
require.NoError(t, err)
|
||||
filterID := createdFilter["id"].(string)
|
||||
filterIDRaw, ok := createdFilter["id"]
|
||||
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
|
||||
filterID := filterIDRaw.(string)
|
||||
|
||||
// Delete filter
|
||||
deleteHTTP, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
|
||||
@@ -223,7 +227,9 @@ func TestSavedFilters(t *testing.T) {
|
||||
var createdFilter map[string]interface{}
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
require.NoError(t, err)
|
||||
filterID := createdFilter["id"].(string)
|
||||
filterIDRaw, ok := createdFilter["id"]
|
||||
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
|
||||
filterID := filterIDRaw.(string)
|
||||
|
||||
// Admin user tries to delete regular user's filter
|
||||
deleteHTTP, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
|
||||
@@ -260,7 +266,9 @@ func TestSavedFilters(t *testing.T) {
|
||||
var createdFilter map[string]interface{}
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
require.NoError(t, err)
|
||||
filterID := createdFilter["id"].(string)
|
||||
filterIDRaw, ok := createdFilter["id"]
|
||||
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
|
||||
filterID := filterIDRaw.(string)
|
||||
|
||||
// Now retrieve the filter by ID
|
||||
getReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
|
||||
@@ -324,7 +332,7 @@ func TestSavedFilters(t *testing.T) {
|
||||
t.Run("GET /api/saved-filters/:id from different user returns 404", func(t *testing.T) {
|
||||
// Create filter with admin user (setup.Token)
|
||||
reqBody := map[string]interface{}{
|
||||
"name": "Admin Filter",
|
||||
"name": "Admin Test Filter",
|
||||
"resource_type": "media-items",
|
||||
"filters": map[string]string{"search": "test"},
|
||||
}
|
||||
@@ -340,7 +348,9 @@ func TestSavedFilters(t *testing.T) {
|
||||
var createdFilter map[string]interface{}
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
require.NoError(t, err)
|
||||
filterID := createdFilter["id"].(string)
|
||||
filterIDRaw, ok := createdFilter["id"]
|
||||
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
|
||||
filterID := filterIDRaw.(string)
|
||||
// Try to access with regular user (setup.RegularToken)
|
||||
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -28,7 +30,21 @@ func TestBrowseLibraryFoldersEndpoint(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("GET /api/libraries/browse - admin can browse directories", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/libraries/browse?path=/tmp", nil)
|
||||
// Self-created fixture dir: the container's /tmp may be empty, and
|
||||
// the endpoint lists DIRECTORIES only — seed two so the listing is
|
||||
// deterministic regardless of environment.
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"alpha", "beta"} {
|
||||
if err := os.Mkdir(filepath.Join(root, name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// A decoy file must NOT appear in the directory listing.
|
||||
if err := os.WriteFile(filepath.Join(root, "stray.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/libraries/browse?path="+root, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
rec := httptest.NewRecorder()
|
||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
@@ -41,8 +57,8 @@ func TestBrowseLibraryFoldersEndpoint(t *testing.T) {
|
||||
}
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &data)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "/tmp", data.CurrentPath)
|
||||
assert.NotEmpty(t, data.Directories)
|
||||
assert.Equal(t, filepath.Clean(root), data.CurrentPath)
|
||||
assert.ElementsMatch(t, []string{"alpha", "beta"}, data.Directories)
|
||||
})
|
||||
|
||||
t.Run("GET /api/libraries/browse - blocks path traversal attempts", func(t *testing.T) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -151,11 +152,15 @@ func TestMediaItemISBNNormalization(t *testing.T) {
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Unique file_path per case: CreateMediaItem upserts ON
|
||||
// CONFLICT (library_id, file_path), so a constant path would
|
||||
// return the FIRST row's ISBN for every later case.
|
||||
slug := strings.ReplaceAll(strings.ToLower(tc.name), " ", "-")
|
||||
payload := map[string]interface{}{
|
||||
"title": fmt.Sprintf("Test Book %s", tc.name),
|
||||
"isbn": tc.input,
|
||||
"library_id": libID,
|
||||
"file_path": "/test/path.epub",
|
||||
"file_path": "/test/" + slug + ".epub",
|
||||
"file_size": 1024,
|
||||
"mime_type": "application/epub+zip",
|
||||
}
|
||||
@@ -203,7 +208,7 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"title": "Book without ISBN",
|
||||
"library_id": libID,
|
||||
"file_path": "/test/path.epub",
|
||||
"file_path": "/test/book-without-isbn.epub",
|
||||
"file_size": 1024,
|
||||
"mime_type": "application/epub+zip",
|
||||
}
|
||||
@@ -228,7 +233,7 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
|
||||
"title": "Multi-Hyphen ISBN",
|
||||
"isbn": "978-0-306-40615-7",
|
||||
"library_id": libID,
|
||||
"file_path": "/test/path.epub",
|
||||
"file_path": "/test/multi-hyphen-isbn.epub",
|
||||
"file_size": 1024,
|
||||
"mime_type": "application/epub+zip",
|
||||
}
|
||||
@@ -258,7 +263,7 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
|
||||
"title": "Trailing Hyphen ISBN",
|
||||
"isbn": "978-0-596-00965-2",
|
||||
"library_id": libID,
|
||||
"file_path": "/test/path.epub",
|
||||
"file_path": "/test/trailing-hyphen-isbn.epub",
|
||||
"file_size": 1024,
|
||||
"mime_type": "application/epub+zip",
|
||||
}
|
||||
@@ -292,13 +297,15 @@ func TestMediaItemsPagination(t *testing.T) {
|
||||
libID := createTestLibrary(t, setup.Server, token, "test-pagination-lib")
|
||||
addFolderToLibrary(t, setup, libID, "/app/uploads")
|
||||
|
||||
// Create some test media items
|
||||
// Create some test media items. Unique file_path per item: the create
|
||||
// endpoint upserts ON CONFLICT (library_id, file_path), so a constant
|
||||
// path would collapse all five creates onto one row.
|
||||
for i := 1; i <= 5; i++ {
|
||||
payload := map[string]interface{}{
|
||||
"title": fmt.Sprintf("Book %d", i),
|
||||
"isbn": fmt.Sprintf("978030640615%d", i),
|
||||
"library_id": libID,
|
||||
"file_path": "/test/path.epub",
|
||||
"file_path": fmt.Sprintf("/test/book-%d.epub", i),
|
||||
"file_size": 1024,
|
||||
"mime_type": "application/epub+zip",
|
||||
}
|
||||
@@ -408,6 +415,62 @@ func TestMediaItemsPagination(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestMediaItemCreateDuplicatePath pins the create endpoint's duplicate
|
||||
// behavior: a POST with a (library_id, file_path) that already exists
|
||||
// answers 200 OK with the EXISTING item and duplicate=true — never a
|
||||
// 201 with the old row pretending to be new, and nothing overwritten.
|
||||
func TestMediaItemCreateDuplicatePath(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
token := setup.Token
|
||||
libID := createTestLibrary(t, setup.Server, token, "test-dup-create-lib")
|
||||
addFolderToLibrary(t, setup, libID, "/app/uploads")
|
||||
|
||||
createPayload := func(title string) []byte {
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"title": title,
|
||||
"author": "First Author",
|
||||
"isbn": "9780306406157",
|
||||
"library_id": libID,
|
||||
"file_path": "/test/dup-target.epub",
|
||||
"file_size": 1024,
|
||||
"mime_type": "application/epub+zip",
|
||||
})
|
||||
return body
|
||||
}
|
||||
|
||||
post := func(body []byte) (*http.Response, []byte) {
|
||||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
return resp, raw
|
||||
}
|
||||
|
||||
resp, raw := post(createPayload("Original Title"))
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, "first create should be 201")
|
||||
var created map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(raw, &created))
|
||||
originalID := created["id"].(string)
|
||||
|
||||
resp, raw = post(createPayload("Different Title"))
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "duplicate create should be 200, not a 201 lie")
|
||||
|
||||
var dup map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(raw, &dup))
|
||||
assert.Equal(t, true, dup["duplicate"], "response must flag the duplicate")
|
||||
assert.Equal(t, originalID, dup["item"].(map[string]interface{})["id"].(string),
|
||||
"duplicate response carries the existing item")
|
||||
assert.Equal(t, "Original Title", dup["item"].(map[string]interface{})["title"],
|
||||
"the second create's payload must NOT overwrite the existing row")
|
||||
}
|
||||
|
||||
// TestMediaItemLibraryRequirement tests that media items require a library
|
||||
func TestMediaItemLibraryRequirement(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
@@ -505,7 +505,11 @@ func TestOPDSLibraryFolders(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("all=1 keeps the flat cross-library catalog", func(t *testing.T) {
|
||||
status, contentType, body := getFeed(t, base+"/catalog?all=1")
|
||||
// Explicit large page: the shared test DB accumulates items, and
|
||||
// the freshly created books sort last — without a generous
|
||||
// per_page they fall onto a later page and the assertion reads
|
||||
// tea leaves instead of the contract.
|
||||
status, contentType, body := getFeed(t, base+"/catalog?all=1&per_page=200")
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, contentType, "kind=acquisition")
|
||||
assert.Contains(t, body, "OPDS Folder Book 1")
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
// OwnershipAndAdminTestSuite pins the 2026-10-13 hardening: per-user
|
||||
// annotation mutations are ownership-scoped (no cross-user delete/update by
|
||||
// bare row id), and admins can create users even with self-service
|
||||
// registration disabled.
|
||||
type OwnershipAndAdminTestSuite struct {
|
||||
suite.Suite
|
||||
setup *TestServerSetup
|
||||
token string
|
||||
itemID string
|
||||
libraryID string
|
||||
absFile string
|
||||
}
|
||||
|
||||
func (s *OwnershipAndAdminTestSuite) SetupSuite() {
|
||||
s.setup = setupTestServer(s.T())
|
||||
s.token = s.setup.Token
|
||||
var libID string
|
||||
s.itemID, libID, s.absFile = s.createItemViaDB(true)
|
||||
s.libraryID = libID
|
||||
// createTestMediaItemID's t.Cleanup path can't be used at suite level
|
||||
// (it fires after TearDownSuite has closed the server), so the item is
|
||||
// created directly through the DB handle; suite cleanup relies on the
|
||||
// standard "@tests.bookhoard.internal / test-named rows" passes of the
|
||||
// next run's setupTestServer.
|
||||
}
|
||||
|
||||
// createItemViaDB makes a throwaway library + media item directly through
|
||||
// the test DB handle — no HTTP, no t.Cleanup entanglement.
|
||||
// With withFolder=true the library gets a REAL temp folder row and the
|
||||
// file is created on disk inside it, so deletes exercise real file
|
||||
// removal; the folder path is returned alongside the item id.
|
||||
func (s *OwnershipAndAdminTestSuite) createItemViaDB(withFolder bool) (itemID, libraryID, absFile string) {
|
||||
ctx := s.setup.QueueCtx
|
||||
db := s.setup.DB
|
||||
|
||||
libraryTypeRow, err := db.GetLibraryTypeByName(ctx, "ebooks")
|
||||
require.NoError(s.T(), err, "should find ebooks library type")
|
||||
|
||||
admin, err := db.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal")
|
||||
require.NoError(s.T(), err, "dev admin user should exist")
|
||||
|
||||
library, err := db.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||
Name: fmt.Sprintf("Ownership Suite Test Library %d", time.Now().UnixNano()),
|
||||
Description: pgtype.Text{String: "annotation ownership suite", Valid: true},
|
||||
LibraryTypeID: libraryTypeRow.ID,
|
||||
CreatedByAdminID: admin.ID,
|
||||
})
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
relPath := fmt.Sprintf("ownership-suite-%d.epub", time.Now().UnixNano())
|
||||
absFile = ""
|
||||
if withFolder {
|
||||
dir, dirErr := os.MkdirTemp("", "ownership-suite-*")
|
||||
require.NoError(s.T(), dirErr)
|
||||
_, ferr := db.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
|
||||
LibraryID: library.ID,
|
||||
FolderPath: dir,
|
||||
})
|
||||
require.NoError(s.T(), ferr)
|
||||
absFile = filepath.Join(dir, relPath)
|
||||
if werr := os.WriteFile(absFile, []byte("restored book"), 0o644); werr != nil {
|
||||
require.NoError(s.T(), werr)
|
||||
}
|
||||
}
|
||||
|
||||
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: library.ID,
|
||||
Title: "Ownership Suite Item",
|
||||
Author: pgtype.Text{String: "Suite Author", Valid: true},
|
||||
FilePath: relPath,
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
})
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
return uuid.UUID(item.ID.Bytes).String(), uuid.UUID(library.ID.Bytes).String(), absFile
|
||||
}
|
||||
|
||||
func (s *OwnershipAndAdminTestSuite) TearDownSuite() {
|
||||
s.setup.Close()
|
||||
}
|
||||
|
||||
func (s *OwnershipAndAdminTestSuite) getItem(itemID string) map[string]interface{} {
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/media-items/"+itemID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
var body map[string]interface{}
|
||||
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&body))
|
||||
return body
|
||||
}
|
||||
|
||||
func (s *OwnershipAndAdminTestSuite) createHighlight(token string) map[string]interface{} {
|
||||
payload, _ := json.Marshal(map[string]interface{}{
|
||||
"selection_text": "owned annotation text",
|
||||
"start_position": "p1",
|
||||
"end_position": "p1",
|
||||
"color": "#ffff00",
|
||||
})
|
||||
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/media-items/"+s.itemID+"/highlights", bytes.NewBuffer(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(s.T(), http.StatusCreated, resp.StatusCode, "highlight create should succeed")
|
||||
var body map[string]interface{}
|
||||
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&body))
|
||||
return body
|
||||
}
|
||||
|
||||
func (s *OwnershipAndAdminTestSuite) highlightExists(token, highlightID string) bool {
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/media-items/"+s.itemID+"/highlights", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer resp.Body.Close()
|
||||
var body []map[string]interface{}
|
||||
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&body))
|
||||
for _, h := range body {
|
||||
if h["id"] == highlightID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestHighlightDelete_CrossUserNoOp pins the ownership predicate: user B's
|
||||
// DELETE of user A's highlight by bare id must not remove A's row.
|
||||
func (s *OwnershipAndAdminTestSuite) TestHighlightDelete_CrossUserNoOp() {
|
||||
regular := s.setup.RegularToken
|
||||
|
||||
created := s.createHighlight(regular)
|
||||
highlightID, ok := created["id"].(string)
|
||||
require.True(s.T(), ok, "create response carries the highlight id")
|
||||
|
||||
// The ADMIN (a different user) tries to delete it by id.
|
||||
req, _ := http.NewRequest("DELETE", s.setup.Server.URL+"/api/media-items/"+s.itemID+"/highlights/"+highlightID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
assert.True(s.T(), s.highlightExists(regular, highlightID),
|
||||
"another user's delete-by-id must not remove the owner's highlight")
|
||||
|
||||
// The owner deletes their own highlight — gone.
|
||||
req, _ = http.NewRequest("DELETE", s.setup.Server.URL+"/api/media-items/"+s.itemID+"/highlights/"+highlightID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+regular)
|
||||
resp, err = (&http.Client{}).Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
assert.False(s.T(), s.highlightExists(regular, highlightID), "owner delete removes the row")
|
||||
}
|
||||
|
||||
// TestAdminCreateUser pins the admin counterpart to registration: admins
|
||||
// create accounts even with self-service registration disabled, regular
|
||||
// users cannot, and duplicates conflict.
|
||||
func (s *OwnershipAndAdminTestSuite) TestAdminCreateUser() {
|
||||
suffix := fmt.Sprintf("%d", s.setup.DBPool.Config().MaxConns) // stable-ish uniquifier
|
||||
unique := fmt.Sprintf("admincreated%s", suffix)
|
||||
|
||||
// Regular user token → 403 (AdminMiddleware).
|
||||
payload, _ := json.Marshal(map[string]interface{}{
|
||||
"email": unique + "@tests.bookhoard.internal",
|
||||
"username": unique,
|
||||
"password": "Test@Pass123!",
|
||||
})
|
||||
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/auth/users", bytes.NewBuffer(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+s.setup.RegularToken)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
require.Equal(s.T(), http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
// Admin → 201, and the account is immediately usable (login works)
|
||||
// even though self-service registration is off for new servers.
|
||||
req, _ = http.NewRequest("POST", s.setup.Server.URL+"/api/auth/users", bytes.NewBuffer(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
resp, err = (&http.Client{}).Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(s.T(), http.StatusCreated, resp.StatusCode)
|
||||
var created map[string]interface{}
|
||||
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&created))
|
||||
assert.Equal(s.T(), unique, created["username"])
|
||||
assert.Equal(s.T(), "user", created["role"])
|
||||
|
||||
// New users inherit the system-wide default timezone (this was the
|
||||
// point of the setting; before 2026-10-13 every new user got UTC
|
||||
// regardless of it).
|
||||
expectedTZ := "UTC"
|
||||
var settingTZ string
|
||||
err = s.setup.DBPool.QueryRow(s.setup.QueueCtx,
|
||||
"SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone'").Scan(&settingTZ)
|
||||
if err == nil {
|
||||
if _, locErr := time.LoadLocation(settingTZ); locErr == nil && settingTZ != "" {
|
||||
expectedTZ = settingTZ
|
||||
}
|
||||
}
|
||||
assert.Equal(s.T(), expectedTZ, created["timezone"],
|
||||
"created user must carry the system default timezone")
|
||||
|
||||
// Duplicate username → 409.
|
||||
req, _ = http.NewRequest("POST", s.setup.Server.URL+"/api/auth/users", bytes.NewBuffer(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
resp, err = (&http.Client{}).Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(s.T(), http.StatusConflict, resp.StatusCode)
|
||||
|
||||
// The created user can log in.
|
||||
loginPayload, _ := json.Marshal(map[string]interface{}{
|
||||
"login": unique + "@tests.bookhoard.internal",
|
||||
"password": "Test@Pass123!",
|
||||
})
|
||||
req, _ = http.NewRequest("POST", s.setup.Server.URL+"/api/auth/login", bytes.NewBuffer(loginPayload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err = (&http.Client{}).Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestDelete_StorageDownRefused pins the NAS guard: deleting a media item
|
||||
// while its library storage is unreachable is REFUSED (row + annotations
|
||||
// survive) instead of cascading annotations for a file that will re-import
|
||||
// bare on the next scan.
|
||||
func (s *OwnershipAndAdminTestSuite) TestDelete_StorageDownRefused() {
|
||||
ctx := s.setup.QueueCtx
|
||||
db := s.setup.DB
|
||||
|
||||
libraryTypeRow, err := db.GetLibraryTypeByName(ctx, "ebooks")
|
||||
require.NoError(s.T(), err)
|
||||
admin, err := db.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal")
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
library, err := db.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||
Name: fmt.Sprintf("Ownership Suite Test Offline %d", time.Now().UnixNano()),
|
||||
Description: pgtype.Text{String: "storage-down suite", Valid: true},
|
||||
LibraryTypeID: libraryTypeRow.ID,
|
||||
CreatedByAdminID: admin.ID,
|
||||
})
|
||||
require.NoError(s.T(), err)
|
||||
// Folder row pointing at a mount that does not exist.
|
||||
_, err = db.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
|
||||
LibraryID: library.ID,
|
||||
FolderPath: "/nonexistent-mount-xyz",
|
||||
})
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: library.ID,
|
||||
Title: "Offline Storage Item",
|
||||
FilePath: "offline.epub",
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
})
|
||||
require.NoError(s.T(), err)
|
||||
itemID := uuid.UUID(item.ID.Bytes).String()
|
||||
|
||||
payload, _ := json.Marshal(map[string]interface{}{"media_item_ids": []string{itemID}})
|
||||
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/media-items/bulk-delete", bytes.NewBuffer(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(s.T(), http.StatusOK, resp.StatusCode, "bulk endpoint answers 200 with per-item errors")
|
||||
|
||||
var body map[string]interface{}
|
||||
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&body))
|
||||
results := body["results"].([]interface{})
|
||||
require.Len(s.T(), results, 1)
|
||||
first := results[0].(map[string]interface{})
|
||||
assert.Contains(s.T(), first["error"], "storage unreachable")
|
||||
|
||||
// The row survived the refused delete.
|
||||
check, err := db.GetMediaItem(ctx, item.ID)
|
||||
require.NoError(s.T(), err)
|
||||
assert.Equal(s.T(), "Offline Storage Item", check.Title)
|
||||
}
|
||||
|
||||
func TestOwnershipAndAdminTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(OwnershipAndAdminTestSuite))
|
||||
}
|
||||
@@ -16,8 +16,7 @@ func TestProcessingIssuesListAuthentication(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
// Create a test library
|
||||
deviceSetup := setupDeviceTest(t)
|
||||
libraryID := deviceSetup.CreateLibrary(t, "Test Auth Library", "manga")
|
||||
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Auth Library", "manga")
|
||||
|
||||
t.Run("GET /api/libraries/:id/issues/list - No authentication", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
|
||||
@@ -55,8 +54,7 @@ func TestProcessingIssuesListResponseStructure(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
// Create a test library
|
||||
deviceSetup := setupDeviceTest(t)
|
||||
libraryID := deviceSetup.CreateLibrary(t, "Test Structure Library", "comics")
|
||||
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Structure Library", "comics")
|
||||
|
||||
t.Run("Returns JSON array for empty library", func(t *testing.T) {
|
||||
token := setup.Token
|
||||
@@ -163,8 +161,7 @@ func TestProcessingIssueStatsAuthentication(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
// Create a test library
|
||||
deviceSetup := setupDeviceTest(t)
|
||||
libraryID := deviceSetup.CreateLibrary(t, "Test Stats Auth Library", "manga")
|
||||
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Stats Auth Library", "manga")
|
||||
|
||||
t.Run("GET /api/libraries/:id/issues/stats - No authentication", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil)
|
||||
@@ -202,8 +199,7 @@ func TestProcessingIssueStatsResponseStructure(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
// Create a test library
|
||||
deviceSetup := setupDeviceTest(t)
|
||||
libraryID := deviceSetup.CreateLibrary(t, "Test Stats Structure Library", "comics")
|
||||
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Stats Structure Library", "comics")
|
||||
|
||||
t.Run("Returns correct structure for empty library", func(t *testing.T) {
|
||||
token := setup.Token
|
||||
@@ -343,11 +339,9 @@ func TestProcessingIssuesCrossLibraryIsolation(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
// Create two different libraries with different types
|
||||
deviceSetup1 := setupDeviceTest(t)
|
||||
library1ID := deviceSetup1.CreateLibrary(t, "Isolation Test Library 1", "manga")
|
||||
library1ID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Isolation Test Library 1", "manga")
|
||||
|
||||
deviceSetup2 := setupDeviceTest(t)
|
||||
library2ID := deviceSetup2.CreateLibrary(t, "Isolation Test Library 2", "comics")
|
||||
library2ID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Isolation Test Library 2", "comics")
|
||||
|
||||
token := setup.Token
|
||||
|
||||
@@ -431,8 +425,7 @@ func TestProcessingIssuesDifferentLibraryTypes(t *testing.T) {
|
||||
|
||||
for _, lt := range libraryTypes {
|
||||
t.Run(lt.name+" - list endpoint works", func(t *testing.T) {
|
||||
deviceSetup := setupDeviceTest(t)
|
||||
libraryID := deviceSetup.CreateLibrary(t, "Test "+lt.name, lt.libraryType)
|
||||
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test "+lt.name, lt.libraryType)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
@@ -448,8 +441,7 @@ func TestProcessingIssuesDifferentLibraryTypes(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run(lt.name+" - stats endpoint works", func(t *testing.T) {
|
||||
deviceSetup := setupDeviceTest(t)
|
||||
libraryID := deviceSetup.CreateLibrary(t, "Test "+lt.name, lt.libraryType)
|
||||
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test "+lt.name, lt.libraryType)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
@@ -474,8 +466,7 @@ func TestProcessingIssuesInvalidTokens(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
// Create a test library
|
||||
deviceSetup := setupDeviceTest(t)
|
||||
libraryID := deviceSetup.CreateLibrary(t, "Test Token Library", "ebooks")
|
||||
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Token Library", "ebooks")
|
||||
|
||||
t.Run("List endpoint rejects invalid token", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
|
||||
|
||||
@@ -656,3 +656,182 @@ func TestProgressWeb_EdgeCases(t *testing.T) {
|
||||
assert.InDelta(t, 1.5, progress.ZoomLevel.Float64, 0.01)
|
||||
})
|
||||
}
|
||||
|
||||
// TestDeletedUserTokenRejected pins the ghost-session guard: a
|
||||
// signature-valid JWT for a user that has been DELETED must stop
|
||||
// authenticating (401 user_not_found), not serve a phantom session.
|
||||
// Before the guard, deleted users kept full API access until token
|
||||
// expiry (observed live on hardware 2026-10-09).
|
||||
func TestDeletedUserTokenRejected(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a dedicated user, then log in as it to get a fresh token.
|
||||
// Admin-created (not self-registered) so the test stays deterministic
|
||||
// regardless of the registration_enabled setting.
|
||||
createResp := doReq(t, "POST", setup.Server.URL+"/api/auth/users", map[string]interface{}{
|
||||
"email": "ghost-test@tests.bookhoard.internal",
|
||||
"username": "ghosttest",
|
||||
"password": "Ghost@Pass123!",
|
||||
}, setup.Token)
|
||||
defer createResp.Body.Close()
|
||||
if createResp.StatusCode != http.StatusCreated {
|
||||
t.Skipf("admin user creation unavailable (status %d)", createResp.StatusCode)
|
||||
}
|
||||
|
||||
loginResp := doReq(t, "POST", setup.Server.URL+"/api/auth/login", map[string]interface{}{
|
||||
"login": "ghost-test@tests.bookhoard.internal",
|
||||
"password": "Ghost@Pass123!",
|
||||
}, "")
|
||||
defer loginResp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||
var loginBody struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(loginResp.Body).Decode(&loginBody))
|
||||
require.NotEmpty(t, loginBody.AccessToken)
|
||||
|
||||
ghostToken := loginBody.AccessToken
|
||||
|
||||
// Sanity: the token works while the user exists (user-scoped endpoint
|
||||
// a freshly-registered regular user can access).
|
||||
okResp := doReq(t, "GET", setup.Server.URL+"/api/collections", nil, ghostToken)
|
||||
defer okResp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, okResp.StatusCode, "token must work while the user exists")
|
||||
|
||||
// Delete the user directly via the DB for determinism (same query the
|
||||
// suite's own cleanup uses).
|
||||
ghostID := ""
|
||||
users, err := setup.DB.ListUsers(ctx)
|
||||
require.NoError(t, err)
|
||||
for _, u := range users {
|
||||
if u.Email == "ghost-test@tests.bookhoard.internal" {
|
||||
ghostID = uuid.UUID(u.ID.Bytes).String()
|
||||
require.NoError(t, setup.DB.DeleteUser(ctx, u.ID))
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, ghostID, "ghost user row must exist before deletion")
|
||||
|
||||
// The same token must now be rejected with 401 user_not_found.
|
||||
resp := doReq(t, "GET", setup.Server.URL+"/api/collections", nil, ghostToken)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "ghost token must be rejected")
|
||||
|
||||
var errBody map[string]string
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&errBody))
|
||||
assert.Contains(t, errBody["message"], "user_not_found")
|
||||
}
|
||||
|
||||
// TestProgressWeb_LocatorCoherence pins the locator coherence rule: a
|
||||
// percentage-only write that DECLARES no locator (ClearLocator) must clear
|
||||
// any inherited stale anchor instead of re-pairing the new percentage with
|
||||
// the previous writer's CFI/context — the incoherent row shape that fed the
|
||||
// client-side far-anchor restore floor. Request-carried fields always win.
|
||||
func TestProgressWeb_LocatorCoherence(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
ctx := context.Background()
|
||||
userID := getTestUserID(t, setup.DB)
|
||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||
|
||||
seedAnchor := func(t *testing.T, mediaItemID string) uuid.UUID {
|
||||
mediaUUID, _ := uuid.Parse(mediaItemID)
|
||||
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
Source: "web",
|
||||
Percentage: pFloat64(0.3),
|
||||
Epubcfi: pStr("epubcfi(/6/4/2:first)"),
|
||||
ContextText: pStr("some anchor context sentence"),
|
||||
DeviceType: "web",
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, progress.Epubcfi.Valid, "seed: anchor stored")
|
||||
return mediaUUID
|
||||
}
|
||||
|
||||
t.Run("percentage-only kobo write clears the inherited anchor", func(t *testing.T) {
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
mediaUUID := seedAnchor(t, mediaItemID)
|
||||
|
||||
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
Source: "kobo",
|
||||
Percentage: pFloat64(0.6),
|
||||
DeviceType: "kobo",
|
||||
ClearLocator: true,
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 0.6, progress.Percentage.Float64, 0.001, "percentage updated")
|
||||
assert.False(t, progress.Epubcfi.Valid, "stale anchor cleared, not re-paired")
|
||||
assert.False(t, progress.ContextText.Valid, "stale context cleared")
|
||||
assert.Equal(t, "kobo", progress.LastSyncSource.String)
|
||||
})
|
||||
|
||||
t.Run("request-carried context survives the clear", func(t *testing.T) {
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
mediaUUID := seedAnchor(t, mediaItemID)
|
||||
|
||||
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
Source: "koreader",
|
||||
Percentage: pFloat64(0.7),
|
||||
ContextText: pStr("a brand new context sentence"),
|
||||
DeviceType: "koreader",
|
||||
ClearLocator: true,
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 0.7, progress.Percentage.Float64, 0.001)
|
||||
assert.True(t, progress.ContextText.Valid, "request-carried context kept")
|
||||
assert.Equal(t, "a brand new context sentence", progress.ContextText.String)
|
||||
// The verify gate is inert in the test harness (no media path
|
||||
// resolver wired), so no anchor can be minted here; the rule
|
||||
// under test is that the STALE one is gone.
|
||||
assert.False(t, progress.Epubcfi.Valid, "stale anchor cleared")
|
||||
})
|
||||
|
||||
t.Run("HTTP merge semantics untouched: key-absent PUT preserves fields", func(t *testing.T) {
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
mediaUUID := seedAnchor(t, mediaItemID)
|
||||
|
||||
// Service-level save WITHOUT ClearLocator mimics the HTTP merge
|
||||
// path (field absent = inherit): the anchor must survive.
|
||||
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
Source: "queue",
|
||||
Percentage: pFloat64(0.4),
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, progress.Epubcfi.Valid, "merge path preserves the anchor")
|
||||
assert.Equal(t, "epubcfi(/6/4/2:first)", progress.Epubcfi.String)
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -34,15 +35,19 @@ func TestUnifiedSearch(t *testing.T) {
|
||||
_ = folderResp.Body.Close()
|
||||
require.Equal(t, http.StatusCreated, folderResp.StatusCode)
|
||||
|
||||
// Helper to create book with fields
|
||||
// Helper to create book with fields. Unique file_path per book: the
|
||||
// create endpoint answers 200/duplicate for a repeated
|
||||
// (library_id, file_path), and the old shared constant made the 2nd/3rd
|
||||
// creates no-ops anyway.
|
||||
createBook := func(title, author, genre string, year int, hasCover bool) {
|
||||
slug := strings.ReplaceAll(strings.ToLower(title), " ", "-")
|
||||
bookReq := map[string]interface{}{
|
||||
"library_id": libraryID,
|
||||
"title": title,
|
||||
"author": author,
|
||||
"genre": genre,
|
||||
"copyright_year": year,
|
||||
"file_path": "/tmp/test.epub",
|
||||
"file_path": "/tmp/" + slug + ".epub",
|
||||
"file_size": 1024,
|
||||
"mime_type": "application/epub+zip",
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ func (s *SeriesIntegrationTestSuite) TearDownSuite() {
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeries_RequiresLibraryID() {
|
||||
// Ground truth (2026-10-13 visibility hardening): library_id stays
|
||||
// optional, but the unscoped aggregate now covers only the requester's
|
||||
// VISIBLE libraries — hidden libraries contribute nothing. Series are
|
||||
// grouped per library (same name in two libraries = two entries), so
|
||||
// every row carries a library_id.
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
@@ -42,12 +47,20 @@ func (s *SeriesIntegrationTestSuite) TestGetSeries_RequiresLibraryID() {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]string
|
||||
var body map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
require.NoError(s.T(), err)
|
||||
assert.Equal(s.T(), "library_id required", body["error"])
|
||||
assert.Contains(s.T(), body, "series", "response carries the series array")
|
||||
assert.Contains(s.T(), body, "total")
|
||||
if rows, ok := body["series"].([]interface{}); ok {
|
||||
for _, r := range rows {
|
||||
row, ok := r.(map[string]interface{})
|
||||
require.True(s.T(), ok)
|
||||
assert.NotEmpty(s.T(), row["library_id"], "series rows carry their library scope")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeries_InvalidLibraryID() {
|
||||
@@ -159,7 +172,13 @@ func (s *SeriesIntegrationTestSuite) TestGetSeries_LimitClampedTo100() {
|
||||
assert.Equal(s.T(), float64(100), limit, "limit should be clamped to 100")
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_RequiresLibraryID() {
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_LibraryIDRequired() {
|
||||
// Ground truth (2026-10-13 visibility hardening): /api/series/books is
|
||||
// per-library now — library_id is REQUIRED, validated, and must be
|
||||
// visible to the requester. The former name-only contract let any
|
||||
// authenticated account pull series books across ALL libraries
|
||||
// (including hidden ones), and merged same-name series across
|
||||
// libraries (manga adaptations / LN splits).
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?name=Test+Series", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
@@ -199,6 +218,7 @@ func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_RequiresName() {
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_InvalidLibraryID() {
|
||||
// library_id is now validated: an unparseable uuid is a 400.
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?library_id=bad-uuid&name=Test", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
@@ -296,6 +316,83 @@ func (s *SeriesIntegrationTestSuite) TestGetSeries_ResponseStructure() {
|
||||
assert.True(s.T(), ok, "'series' should be an array")
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) setLibraryVisibility(token, libraryID string, visible bool) *http.Response {
|
||||
payload, _ := json.Marshal(map[string]interface{}{
|
||||
"library_id": libraryID,
|
||||
"is_visible": visible,
|
||||
})
|
||||
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/libraries/visibility", bytes.NewBuffer(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
return resp
|
||||
}
|
||||
|
||||
// TestGetSeries_HiddenLibraryScopedOut pins the family-lens contract: a
|
||||
// library hidden from a user contributes NOTHING to that user's series
|
||||
// views, and naming it explicitly is a 403, not a silent pass — across
|
||||
// /api/series, /api/series/books, and /api/media-items.
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeries_HiddenLibraryScopedOut() {
|
||||
regular := s.setup.RegularToken
|
||||
|
||||
// Baseline: the regular user can see the suite library.
|
||||
resp := s.setLibraryVisibility(regular, s.libraryID, true)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Hide it from the regular user.
|
||||
resp = s.setLibraryVisibility(regular, s.libraryID, false)
|
||||
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
defer func() {
|
||||
r := s.setLibraryVisibility(regular, s.libraryID, true)
|
||||
_ = r.Body.Close()
|
||||
}()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
// Explicit hidden library → 403.
|
||||
for _, path := range []string{
|
||||
"/api/series?library_id=" + s.libraryID,
|
||||
"/api/series/books?library_id=" + s.libraryID + "&name=Test",
|
||||
"/api/media-items?library_id=" + s.libraryID,
|
||||
} {
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+regular)
|
||||
r, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
r.Body.Close()
|
||||
require.Equalf(s.T(), http.StatusForbidden, r.StatusCode, "%s must be 403 for a hidden library (body: %s)", path, body)
|
||||
}
|
||||
|
||||
// Unscoped aggregate: the hidden library's series never appear.
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+regular)
|
||||
r, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer r.Body.Close()
|
||||
require.Equal(s.T(), http.StatusOK, r.StatusCode)
|
||||
var body map[string]interface{}
|
||||
require.NoError(s.T(), json.NewDecoder(r.Body).Decode(&body))
|
||||
if rows, ok := body["series"].([]interface{}); ok {
|
||||
for _, row := range rows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
require.True(s.T(), ok)
|
||||
assert.NotEqual(s.T(), s.libraryID, m["library_id"], "hidden library must not appear in the aggregate")
|
||||
}
|
||||
}
|
||||
|
||||
// The admin still sees the library (default-allow; visibility rows are
|
||||
// per-user).
|
||||
req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/series?library_id="+s.libraryID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
r, err = client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer r.Body.Close()
|
||||
assert.Equal(s.T(), http.StatusOK, r.StatusCode)
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestRestoreSystemCollection_ContinueSeries() {
|
||||
reqBody := map[string]interface{}{
|
||||
"collection_name": "Continue Series",
|
||||
@@ -349,11 +446,17 @@ func (s *SeriesIntegrationTestSuite) TestGetSections_IncludesContinueSeries() {
|
||||
sectionIDs[section["id"].(string)] = true
|
||||
}
|
||||
|
||||
assert.Contains(s.T(), sectionIDs, "continue-series", "dashboard should include continue-series section")
|
||||
assert.Contains(s.T(), sectionIDs, "continue-reading")
|
||||
assert.Contains(s.T(), sectionIDs, "recently-added")
|
||||
assert.Contains(s.T(), sectionIDs, "recently-read")
|
||||
assert.Contains(s.T(), sectionIDs, "not-started")
|
||||
// Ground truth: the /sections contract keys a section's identity on
|
||||
// the collection NAME — BuildSections sets ID = CollectionName, and
|
||||
// the web/app reorder + hidden-collection preferences persist those
|
||||
// same strings (data-collection-id). The kebab "query_type"
|
||||
// ("continue-series" etc.) is the STORAGE key for system collections
|
||||
// and is not exposed on this endpoint.
|
||||
assert.Contains(s.T(), sectionIDs, "Continue Series", "dashboard should include Continue Series")
|
||||
assert.Contains(s.T(), sectionIDs, "Continue Reading")
|
||||
assert.Contains(s.T(), sectionIDs, "Recently Added")
|
||||
assert.Contains(s.T(), sectionIDs, "Recently Read")
|
||||
assert.Contains(s.T(), sectionIDs, "Not Started")
|
||||
}
|
||||
|
||||
func TestSeriesIntegrationTestSuite(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
)
|
||||
|
||||
// Reading-statistics coverage: session CAPTURE at the sync chokepoint,
|
||||
// the summary aggregation (tz buckets, streaks, finished detection),
|
||||
// range validation, the widget, and the per-book card.
|
||||
//
|
||||
// All seeded instants anchor to USER-TZ midnights (the test user is UTC)
|
||||
// so day buckets are deterministic regardless of when the suite runs.
|
||||
|
||||
func pgUser(id uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: [16]byte(id), Valid: true} }
|
||||
func pgFloat(v float64) pgtype.Float8 { return pgtype.Float8{Float64: v, Valid: true} }
|
||||
func pgInt4(v int32) pgtype.Int4 { return pgtype.Int4{Int32: v, Valid: true} }
|
||||
func pgTS(v time.Time) pgtype.Timestamptz {
|
||||
return pgtype.Timestamptz{Time: v, Valid: !v.IsZero()}
|
||||
}
|
||||
|
||||
// middayDaysAgo returns noon UTC N calendar days before today — a
|
||||
// bucket-stable instant for any wall-clock time the suite runs at.
|
||||
func middayDaysAgo(daysAgo int) time.Time {
|
||||
midnight := time.Now().UTC()
|
||||
midnight = time.Date(midnight.Year(), midnight.Month(), midnight.Day(), 0, 0, 0, 0, time.UTC)
|
||||
return midnight.AddDate(0, 0, -daysAgo).Add(12 * time.Hour)
|
||||
}
|
||||
|
||||
func statsLogin(t *testing.T, setup *TestServerSetup, token, path string) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
t.Setenv("STATS_LAST_BODY_PATH", path) // no-op; keeps body-close ordering explicit
|
||||
}()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
return resp, body
|
||||
}
|
||||
|
||||
func TestReadingStatsCapture(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
userID := getTestUserID(t, setup.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
bookID := createTestMediaItemID(t, setup)
|
||||
|
||||
push := func(body map[string]interface{}) int {
|
||||
payload, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/media-items/"+bookID+"/progress", bytes.NewBuffer(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
require.Equal(t, http.StatusOK, push(map[string]interface{}{
|
||||
"percentage": 0.10, "current_page": 10, "total_pages": 100,
|
||||
}))
|
||||
|
||||
time.Sleep(1100 * time.Millisecond) // the second push derives a ~1s span
|
||||
|
||||
require.Equal(t, http.StatusOK, push(map[string]interface{}{
|
||||
"percentage": 0.15, "current_page": 15, "total_pages": 100,
|
||||
}))
|
||||
|
||||
rows, err := setup.DB.GetBookReadingSessions(ctx, database.GetBookReadingSessionsParams{
|
||||
UserID: pgUser(userID),
|
||||
MediaItemID: uuidToPGType(uuid.MustParse(bookID)),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 2, "each push records one session row")
|
||||
|
||||
first := rows[0]
|
||||
assert.Equal(t, int32(0), first.TimeSpentSeconds.Int32, "first contact: marker with zero time")
|
||||
assert.InEpsilon(t, 0.10, first.ProgressPercentage.Float64, 0.001)
|
||||
|
||||
second := rows[1]
|
||||
assert.GreaterOrEqual(t, second.TimeSpentSeconds.Int32, int32(1), "the ~1.1s gap is attributed")
|
||||
assert.LessOrEqual(t, second.TimeSpentSeconds.Int32, int32(10), "and bounded by the slept interval")
|
||||
require.True(t, second.PagesRead.Valid, "page info was carried on both pushes")
|
||||
assert.Equal(t, int32(5), second.PagesRead.Int32, "pages: 15 − 10")
|
||||
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: uuidToPGType(uuid.MustParse(bookID)),
|
||||
UserID: pgUser(userID),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.InEpsilon(t, 0.15, progress.Percentage.Float64, 0.001, "capture never disturbs stored position")
|
||||
}
|
||||
|
||||
func TestReadingStatsDeviceAttribution(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
userID := getTestUserID(t, setup.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
bookID := createTestMediaItemID(t, setup)
|
||||
|
||||
// A registered device owned by the test user, plus one owned by a
|
||||
// DIFFERENT user (the foreign token must be ignored, never sent).
|
||||
device, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
|
||||
UserID: pgUser(userID),
|
||||
DeviceName: "Pixel 9 Test",
|
||||
DeviceType: "mobile",
|
||||
DeviceIdentifier: "stats-test-android-" + uuid.NewString(),
|
||||
AuthToken: "dev_test_token_" + uuid.NewString(),
|
||||
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
|
||||
DeviceMetadata: []byte("{}"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, device.ID) })
|
||||
|
||||
otherUser := getRegularUserID(t, setup.DB)
|
||||
foreign, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
|
||||
UserID: pgUser(otherUser),
|
||||
DeviceName: "Foreign Device",
|
||||
DeviceType: "mobile",
|
||||
DeviceIdentifier: "stats-test-foreign-" + uuid.NewString(),
|
||||
AuthToken: "dev_foreign_token_" + uuid.NewString(),
|
||||
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
|
||||
DeviceMetadata: []byte("{}"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, foreign.ID) })
|
||||
|
||||
push := func(body map[string]interface{}, deviceToken string) int {
|
||||
payload, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/media-items/"+bookID+"/progress", bytes.NewBuffer(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
if deviceToken != "" {
|
||||
req.Header.Set("X-Device-Token", deviceToken)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
// Registered device push → attributed to the device with its name.
|
||||
require.Equal(t, http.StatusOK, push(map[string]interface{}{
|
||||
"percentage": 0.10, "current_page": 10, "total_pages": 100,
|
||||
}, device.AuthToken))
|
||||
|
||||
rows, err := setup.DB.GetBookReadingSessions(ctx, database.GetBookReadingSessionsParams{
|
||||
UserID: pgUser(userID),
|
||||
MediaItemID: uuidToPGType(uuid.MustParse(bookID)),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
require.True(t, rows[0].DeviceID.Valid)
|
||||
assert.Equal(t, uuid.UUID(device.ID.Bytes).String(), uuid.UUID(rows[0].DeviceID.Bytes).String())
|
||||
var meta struct {
|
||||
Source string `json:"source"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rows[0].DeviceMetadata, &meta))
|
||||
assert.Equal(t, "Pixel 9 Test", meta.Source, "capture metadata carries the registered device name")
|
||||
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: uuidToPGType(uuid.MustParse(bookID)),
|
||||
UserID: pgUser(userID),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Pixel 9 Test", progress.LastSyncSource.String, "last-sync surface shows the device name")
|
||||
|
||||
// Foreign device token → soft-fails: unattributed, plain web source.
|
||||
require.Equal(t, http.StatusOK, push(map[string]interface{}{
|
||||
"percentage": 0.20, "current_page": 20, "total_pages": 100,
|
||||
}, "dev_foreign_token_"+uuid.NewString() /* nonexistent token */))
|
||||
|
||||
// A REAL foreign token must also be ignored, not adopted.
|
||||
require.Equal(t, http.StatusOK, push(map[string]interface{}{
|
||||
"percentage": 0.25, "current_page": 25, "total_pages": 100,
|
||||
}, foreign.AuthToken))
|
||||
|
||||
rows, err = setup.DB.GetBookReadingSessions(ctx, database.GetBookReadingSessionsParams{
|
||||
UserID: pgUser(userID),
|
||||
MediaItemID: uuidToPGType(uuid.MustParse(bookID)),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 3)
|
||||
for _, row := range rows[1:] {
|
||||
assert.False(t, row.DeviceID.Valid, "invalid/foreign tokens never attribute a device")
|
||||
require.NoError(t, json.Unmarshal(row.DeviceMetadata, &meta))
|
||||
assert.Equal(t, "web", meta.Source)
|
||||
}
|
||||
progress, err = setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: uuidToPGType(uuid.MustParse(bookID)),
|
||||
UserID: pgUser(userID),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "web", progress.LastSyncSource.String)
|
||||
}
|
||||
|
||||
func TestReadingStatsSummary(t *testing.T) {
|
||||
|
||||
setup := setupTestServer(t)
|
||||
// The REGULAR test user, deliberately: the shared dev DB carries a
|
||||
// live reader on `testuser` (the dev account genuinely reads while
|
||||
// the suite runs), and exact-total assertions cannot share an
|
||||
// account with reality. The fresh regular user has no live pushes.
|
||||
userID := getRegularUserID(t, setup.DB)
|
||||
ctx := context.Context(context.Background())
|
||||
|
||||
libIDStr := createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Stats Library %d", time.Now().UnixNano()), "ebooks")
|
||||
libUUID := uuid.MustParse(libIDStr)
|
||||
|
||||
var finishedBookID, offRangeBookID string
|
||||
// A real registered device: attributed sessions carry its id (the
|
||||
// rest fall into the "Web / unsynced" bucket).
|
||||
seedDevice, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
|
||||
UserID: pgUser(userID),
|
||||
DeviceName: "Kindle Papered Test",
|
||||
DeviceType: "koreader",
|
||||
DeviceIdentifier: "stats-test-kindle-" + uuid.NewString(),
|
||||
AuthToken: "dev_kindle_token_" + uuid.NewString(),
|
||||
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
|
||||
DeviceMetadata: []byte("{}"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, seedDevice.ID) })
|
||||
deviceID := seedDevice.ID
|
||||
|
||||
insertSession := func(daysAgo int, minutes int32, pages int32, pct float64, title string, attributed bool, reuseMediaID ...string) string {
|
||||
var media database.MediaItems
|
||||
var err error
|
||||
if len(reuseMediaID) > 0 && reuseMediaID[0] != "" {
|
||||
media, err = setup.DB.GetMediaItem(ctx, uuidToPGType(uuid.MustParse(reuseMediaID[0])))
|
||||
require.NoError(t, err, "reuse seeded media item")
|
||||
} else {
|
||||
media, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: [16]byte(libUUID), Valid: true},
|
||||
Title: title,
|
||||
FilePath: fmt.Sprintf("/tmp/stats-%s-%d.epub", title, time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
})
|
||||
require.NoError(t, err, "seed media item")
|
||||
}
|
||||
|
||||
deviceIDArg := pgtype.UUID{}
|
||||
if attributed {
|
||||
deviceIDArg = deviceID
|
||||
}
|
||||
|
||||
pushAt := middayDaysAgo(daysAgo)
|
||||
_, err = setup.DB.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{
|
||||
UserID: pgUser(userID),
|
||||
MediaItemID: media.ID,
|
||||
DeviceID: deviceIDArg,
|
||||
ProgressPercentage: pgFloat(pct),
|
||||
ReadingSessionStart: pgTS(pushAt.Add(-time.Duration(minutes) * time.Minute)),
|
||||
ReadingSessionEnd: pgTS(pushAt),
|
||||
PagesRead: pgInt4(pages),
|
||||
TimeSpentSeconds: pgInt4(minutes * 60),
|
||||
DeviceMetadata: []byte(`{"source":"test"}`),
|
||||
})
|
||||
require.NoError(t, err, "seed session")
|
||||
|
||||
id, _ := uuid.FromBytes(media.ID.Bytes[0:16])
|
||||
return id.String()
|
||||
}
|
||||
|
||||
// Device-split scenario: attributed (80 min) + unattributed (75 min).
|
||||
finishedBookID = insertSession(0, 30, 12, 1.0, "Stats Finished Book", false)
|
||||
insertSession(1, 45, 20, 0.5, "Stats Streak Book", false)
|
||||
insertSession(2, 60, 25, 0.42, "Stats Back Book", true)
|
||||
insertSession(2, 20, 8, 0.9, "Stats Finished Book (same book, second session)", true, finishedBookID)
|
||||
offRangeBookID = insertSession(400, 120, 40, 0.2, "Stats OffRange Book", false)
|
||||
|
||||
// Summary -----------------------------------------------------------
|
||||
resp, body := statsLogin(t, setup, setup.RegularToken, "/api/stats/summary?range=7")
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var summary struct {
|
||||
Range string `json:"range"`
|
||||
TotalMinutes int `json:"total_minutes"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
SessionCount int `json:"session_count"`
|
||||
BooksActive int `json:"books_with_activity"`
|
||||
BooksFinished int `json:"books_finished"`
|
||||
CompletionRate float64 `json:"completion_rate"`
|
||||
Daily []struct {
|
||||
Date string `json:"date"`
|
||||
Minutes int `json:"minutes"`
|
||||
Pages int `json:"pages"`
|
||||
} `json:"daily"`
|
||||
Streak struct {
|
||||
Current int `json:"current"`
|
||||
Longest int `json:"longest"`
|
||||
ActiveToday bool `json:"active_today"`
|
||||
} `json:"streak"`
|
||||
TopBooks []struct {
|
||||
MediaItemID string `json:"media_item_id"`
|
||||
Minutes int `json:"minutes"`
|
||||
} `json:"top_books"`
|
||||
LibraryBreakdown []struct {
|
||||
Name string `json:"name"`
|
||||
Minutes int `json:"minutes"`
|
||||
} `json:"library_breakdown"`
|
||||
DeviceBreakdown []struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Minutes int `json:"minutes"`
|
||||
Sessions int `json:"sessions"`
|
||||
} `json:"device_breakdown"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(body, &summary))
|
||||
|
||||
assert.Equal(t, "7", summary.Range)
|
||||
require.Len(t, summary.Daily, 7, "zero-filled inclusive calendar")
|
||||
assert.Equal(t, 155, summary.TotalMinutes, "30+45+60+20 (the 400-day-day session is out of range)")
|
||||
assert.Equal(t, 65, summary.TotalPages, "12+20+25+8")
|
||||
assert.Equal(t, 4, summary.SessionCount)
|
||||
assert.Equal(t, 3, summary.BooksActive, "distinct books with sessions")
|
||||
assert.Equal(t, 1, summary.BooksFinished, "finished := any push ≥ 99.5%")
|
||||
assert.InEpsilon(t, float64(1)/float64(3), summary.CompletionRate, 0.001)
|
||||
|
||||
// Streaks: today, −1d, −2d are consecutive; the lone −400d day isn't.
|
||||
t.Logf("STREAK DUMP: %+v (values %#v)", summary.Streak, summary.Streak)
|
||||
t.Logf("DAILY DUMP: %+v", summary.Daily)
|
||||
assert.Equal(t, 3, summary.Streak.Current)
|
||||
assert.Equal(t, 3, summary.Streak.Longest)
|
||||
assert.True(t, summary.Streak.ActiveToday)
|
||||
|
||||
// Top books: minutes-ordered — the seeded 60-minute back book tops.
|
||||
require.NotEmpty(t, summary.TopBooks)
|
||||
assert.Equal(t, 60, summary.TopBooks[0].Minutes)
|
||||
for i := 1; i < len(summary.TopBooks); i++ {
|
||||
assert.GreaterOrEqual(t, summary.TopBooks[i-1].Minutes, summary.TopBooks[i].Minutes, "top books sorted by minutes")
|
||||
}
|
||||
|
||||
// Library breakdown carries the seeded library.
|
||||
found := false
|
||||
for _, lib := range summary.LibraryBreakdown {
|
||||
if lib.Minutes >= 155 {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "library breakdown aggregates the seeded library")
|
||||
|
||||
// Device breakdown: attributed (80) + unattributed (75) reconcile
|
||||
// against the headline total; both buckets present.
|
||||
require.Len(t, summary.DeviceBreakdown, 2, "one device bucket + one unattributed bucket")
|
||||
var kindleFound, unattributedFound bool
|
||||
for _, dev := range summary.DeviceBreakdown {
|
||||
if dev.Name == "Kindle Papered Test" {
|
||||
kindleFound = true
|
||||
assert.Equal(t, 80, dev.Minutes, "60 + 20 attributed sessions")
|
||||
assert.Equal(t, 2, dev.Sessions)
|
||||
assert.NotEqual(t, "", dev.DeviceID, "device bucket carries its id")
|
||||
assert.Equal(t, "koreader", dev.Type)
|
||||
}
|
||||
if dev.Name == "Web" {
|
||||
unattributedFound = true
|
||||
assert.Equal(t, 75, dev.Minutes, "30 + 45 unattributed sessions")
|
||||
assert.Equal(t, 2, dev.Sessions)
|
||||
assert.Equal(t, "", dev.DeviceID)
|
||||
}
|
||||
}
|
||||
assert.True(t, kindleFound, "the registered device has its own bucket")
|
||||
assert.True(t, unattributedFound, "device-less pushes aggregate into the Web bucket")
|
||||
deviceMinutes := 0
|
||||
for _, dev := range summary.DeviceBreakdown {
|
||||
deviceMinutes += dev.Minutes
|
||||
}
|
||||
assert.Equal(t, 155, deviceMinutes, "device buckets reconcile with the total")
|
||||
|
||||
// Range validation ---------------------------------------------------
|
||||
badResp, badBody := statsLogin(t, setup, setup.RegularToken, "/api/stats/summary?range=fortnight")
|
||||
assert.Equal(t, http.StatusBadRequest, badResp.StatusCode)
|
||||
assert.Contains(t, string(badBody), "invalid range")
|
||||
|
||||
// Auth gate ----------------------------------------------------------
|
||||
anonym, err := http.Get(setup.Server.URL + "/api/stats/summary")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusUnauthorized, anonym.StatusCode)
|
||||
_ = anonym.Body.Close()
|
||||
|
||||
// Widget -------------------------------------------------------------
|
||||
resp, body = statsLogin(t, setup, setup.RegularToken, "/api/stats/this-week")
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var week struct {
|
||||
WeekMinutes int `json:"week_minutes"`
|
||||
WeekBooks int `json:"week_books"`
|
||||
ActiveToday bool `json:"active_today"`
|
||||
CurrentStreak int `json:"current_streak"`
|
||||
PrevMinutes int `json:"prev_week_minutes"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(body, &week))
|
||||
assert.Equal(t, 155, week.WeekMinutes)
|
||||
assert.Equal(t, 3, week.WeekBooks)
|
||||
assert.True(t, week.ActiveToday)
|
||||
assert.Equal(t, 3, week.CurrentStreak)
|
||||
assert.Equal(t, 0, week.PrevMinutes)
|
||||
|
||||
// Per-book card (lifetime, so the 400-day book still totals) -----------
|
||||
resp, body = statsLogin(t, setup, setup.RegularToken, "/api/stats/books/"+offRangeBookID)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var bookStats struct {
|
||||
TotalMinutes int `json:"total_minutes"`
|
||||
SessionCount int `json:"session_count"`
|
||||
MaxProgress float64 `json:"max_progress"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(body, &bookStats))
|
||||
assert.Equal(t, 120, bookStats.TotalMinutes)
|
||||
assert.Equal(t, 1, bookStats.SessionCount)
|
||||
assert.InEpsilon(t, 0.2, bookStats.MaxProgress, 0.001)
|
||||
|
||||
_ = finishedBookID
|
||||
}
|
||||
|
||||
// TestReadingStatsWeekLibraryScope: the dashboard widget tracks the
|
||||
// switched library — this-week totals and streaks scope to the
|
||||
// requested library (and stay global without one).
|
||||
func TestReadingStatsWeekLibraryScope(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
userID := getRegularUserID(t, setup.DB) // isolated from the live dev-reader account
|
||||
ctx := context.Context(context.Background())
|
||||
|
||||
libA := uuid.MustParse(createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Scope LibA %d", time.Now().UnixNano()), "ebooks"))
|
||||
libB := uuid.MustParse(createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Scope LibB %d", time.Now().UnixNano()), "ebooks"))
|
||||
|
||||
seed := func(lib uuid.UUID, title string, daysAgo int, minutes int32) {
|
||||
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
|
||||
Title: title,
|
||||
FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
pushAt := middayDaysAgo(daysAgo)
|
||||
_, err = setup.DB.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{
|
||||
UserID: pgUser(userID),
|
||||
MediaItemID: media.ID,
|
||||
ProgressPercentage: pgFloat(0.3),
|
||||
ReadingSessionStart: pgTS(pushAt.Add(-time.Duration(minutes) * time.Minute)),
|
||||
ReadingSessionEnd: pgTS(pushAt),
|
||||
PagesRead: pgInt4(5),
|
||||
TimeSpentSeconds: pgInt4(minutes * 60),
|
||||
DeviceMetadata: []byte(`{"source":"test"}`),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
seed(libA, "Scope A Today", 0, 30)
|
||||
seed(libA, "Scope A Yesterday", 1, 20)
|
||||
seed(libB, "Scope B Today", 0, 45)
|
||||
|
||||
week := func(libraryID string) map[string]interface{} {
|
||||
url := setup.Server.URL + "/api/stats/this-week"
|
||||
if libraryID != "" {
|
||||
url += "?library_id=" + libraryID
|
||||
}
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var out map[string]interface{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
|
||||
return out
|
||||
}
|
||||
|
||||
num := func(m map[string]interface{}, key string) int { return int(m[key].(float64)) }
|
||||
|
||||
all := week("")
|
||||
assert.Equal(t, 95, num(all, "week_minutes"), "no library filter = all visible libraries")
|
||||
assert.Equal(t, 2, num(all, "current_streak"), "today + yesterday (libA)")
|
||||
|
||||
onlyA := week(libA.String())
|
||||
assert.Equal(t, 50, num(onlyA, "week_minutes"))
|
||||
assert.Equal(t, 2, num(onlyA, "current_streak"))
|
||||
assert.True(t, onlyA["active_today"].(bool))
|
||||
|
||||
onlyB := week(libB.String())
|
||||
assert.Equal(t, 45, num(onlyB, "week_minutes"))
|
||||
assert.Equal(t, 1, num(onlyB, "current_streak"), "libB only has today")
|
||||
|
||||
// A library owned by someone else (invisible to this user) leaks nothing.
|
||||
hidden := uuid.MustParse(createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), fmt.Sprintf("Test Scope Hidden %d", time.Now().UnixNano()), "ebooks"))
|
||||
assert.Equal(t, 0, num(week(hidden.String()), "week_minutes"), "invisible library contributes nothing")
|
||||
|
||||
// Malformed scope is a clean 400.
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/stats/this-week?library_id=nope", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
@@ -32,12 +32,16 @@ func setupSyncTestDB(t *testing.T) *database.Queries {
|
||||
|
||||
db := database.New(dbPool)
|
||||
t.Cleanup(func() {
|
||||
// Scoped wipes ONLY: reading_history now carries real captured
|
||||
// sessions for the shared dev accounts, so a table-wide DELETE
|
||||
// here silently destroyed a live reader's stats data on every
|
||||
// suite run (2026-10-14). Sync-test sessions belong to the
|
||||
// test-sync users; deleting those users cascades their rows.
|
||||
_, _ = dbPool.Exec(ctx, "DELETE FROM sync_queue WHERE true")
|
||||
_, _ = dbPool.Exec(ctx, "DELETE FROM reading_history WHERE true")
|
||||
_, _ = dbPool.Exec(ctx, "DELETE FROM users WHERE email LIKE 'test-sync%'")
|
||||
_, _ = dbPool.Exec(ctx, "DELETE FROM media_items WHERE title LIKE 'Test %'")
|
||||
_, _ = dbPool.Exec(ctx, "DELETE FROM libraries WHERE name LIKE 'Test %'")
|
||||
_, _ = dbPool.Exec(ctx, "DELETE FROM devices WHERE device_name LIKE 'Test %'")
|
||||
_, _ = dbPool.Exec(ctx, "DELETE FROM users WHERE email LIKE 'test-sync%'")
|
||||
dbPool.Close()
|
||||
})
|
||||
|
||||
@@ -55,6 +59,7 @@ func createSyncTestUser(t *testing.T, db *database.Queries) pgtype.UUID {
|
||||
FirstName: pgtype.Text{String: "Test", Valid: true},
|
||||
LastName: pgtype.Text{String: "Sync", Valid: true},
|
||||
Role: "user",
|
||||
Timezone: pgtype.Text{String: "UTC", Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -34,14 +35,18 @@ func TestTagsFilter(t *testing.T) {
|
||||
_ = folderResp.Body.Close()
|
||||
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Should add folder to library")
|
||||
|
||||
// Helper to create book with tags
|
||||
// Helper to create book with tags. The file_path must be UNIQUE per
|
||||
// book: CreateMediaItem upserts ON CONFLICT (library_id, file_path),
|
||||
// so a constant path collapses every create onto the first row and
|
||||
// the later books' tags never land.
|
||||
createBook := func(title, author string, tags []string) {
|
||||
slug := strings.ReplaceAll(strings.ToLower(title), " ", "-")
|
||||
bookReq := map[string]interface{}{
|
||||
"library_id": libraryID,
|
||||
"title": title,
|
||||
"author": author,
|
||||
"tags": tags,
|
||||
"file_path": "/tmp/test.epub",
|
||||
"file_path": "/tmp/" + slug + ".epub",
|
||||
"file_size": 1024,
|
||||
"mime_type": "application/epub+zip",
|
||||
}
|
||||
|
||||
@@ -109,6 +109,18 @@ func createTestLibraryWithFolder(t *testing.T, ts *httptest.Server, token, name
|
||||
require.NoError(t, err)
|
||||
libraryID := libResponse["id"].(string)
|
||||
|
||||
// Every library a test creates must not outlive that test — delete
|
||||
// it on cleanup so runs leave no residue in the dev database.
|
||||
t.Cleanup(func() {
|
||||
delHTTP, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/api/libraries/%s", ts.URL, libraryID), nil)
|
||||
delHTTP.Header.Set("Authorization", "Bearer "+token)
|
||||
delResp, err := client.Do(delHTTP)
|
||||
if err == nil {
|
||||
_, _ = io.Copy(io.Discard, delResp.Body)
|
||||
_ = delResp.Body.Close()
|
||||
}
|
||||
})
|
||||
|
||||
if withFolder {
|
||||
folderReq := map[string]interface{}{
|
||||
"folder_path": "/app/uploads",
|
||||
|
||||
@@ -246,6 +246,7 @@ func createRegularUserOnce(t *testing.T, db *database.Queries) UserTestData {
|
||||
FirstName: pgtype.Text{String: "Regular", Valid: true},
|
||||
LastName: pgtype.Text{String: "User", Valid: true},
|
||||
Role: "user",
|
||||
Timezone: pgtype.Text{String: "UTC", Valid: true},
|
||||
})
|
||||
require.NoError(t, err, "Should create regular test user")
|
||||
|
||||
@@ -275,6 +276,10 @@ func uuidToPGType(u uuid.UUID) pgtype.UUID {
|
||||
// createDefaultCollectionsForUser creates the 4 default system collections for a user
|
||||
func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID pgtype.UUID) {
|
||||
ctx := context.Background()
|
||||
// Canonical Title-case names — the same list the registration seed
|
||||
// (CreateDefaultCollectionsForUser) and RestoreSystemCollection use.
|
||||
// A stale kebab-case copy lived here once and duplicated every
|
||||
// system collection in the shared dev DB.
|
||||
defaultCollections := []struct {
|
||||
Name string
|
||||
Description string
|
||||
@@ -283,14 +288,34 @@ func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID
|
||||
QueryType string
|
||||
Priority int32
|
||||
}{
|
||||
{"continue-reading", "Books you're currently reading (0 < progress < 1)", "📖", "#7aa2f7", "continue-reading", 1},
|
||||
{"recently-added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2},
|
||||
{"recently-read", "Books you've finished (progress >= 1)", "✅", "#e0af68", "recently-read", 3},
|
||||
{"not-started", "Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", "not-started", 4},
|
||||
{"continue-series", "Next book in series you're reading", "📚", "#bb9af7", "continue-series", 5},
|
||||
{"Continue Reading", "Books you're currently reading (0 < progress < 1)", "📖", "#7aa2f7", "continue-reading", 1},
|
||||
{"Recently Added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2},
|
||||
{"Recently Read", "Books you've finished (progress >= 1)", "✅", "#e0af68", "recently-read", 3},
|
||||
{"Not Started", "Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", "not-started", 4},
|
||||
{"Continue Series", "Next book in series you're reading", "📚", "#bb9af7", "continue-series", 5},
|
||||
}
|
||||
|
||||
for _, col := range defaultCollections {
|
||||
// Idempotent by the STABLE identity (user + query_type): existing
|
||||
// rows (renamed, tombstoned) heal to canonical metadata in place;
|
||||
// insert only when the query type has no row. A blind insert here
|
||||
// hit UNIQUE(user_id, name) on every preserved-admin rerun.
|
||||
existing, gerr := db.GetSystemCollectionByQueryType(ctx, database.GetSystemCollectionByQueryTypeParams{
|
||||
UserID: userID,
|
||||
QueryType: pgtype.Text{String: col.QueryType, Valid: true},
|
||||
})
|
||||
if gerr == nil {
|
||||
_, uerr := db.RestoreSystemCollectionRow(ctx, database.RestoreSystemCollectionRowParams{
|
||||
ID: existing.ID,
|
||||
Name: col.Name,
|
||||
Description: pgtype.Text{String: col.Description, Valid: true},
|
||||
Icon: pgtype.Text{String: col.Icon, Valid: true},
|
||||
Color: pgtype.Text{String: col.Color, Valid: true},
|
||||
Priority: pgtype.Int4{Int32: col.Priority, Valid: true},
|
||||
})
|
||||
require.NoError(t, uerr, "Should restore default collection: "+col.Name)
|
||||
continue
|
||||
}
|
||||
_, err := db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
|
||||
UserID: userID,
|
||||
Name: col.Name,
|
||||
@@ -482,6 +507,8 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||
dashboardService := services.NewDashboardService(queries)
|
||||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
||||
statisticsService := services.NewStatisticsService(queries)
|
||||
statsHandler := handlers.NewStatsHandler(statisticsService)
|
||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||||
mediaHandler.SetProgressService(progressService)
|
||||
@@ -528,6 +555,8 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
WSHandler: wsHandler,
|
||||
ConflictHandler: conflictHandler,
|
||||
AnalyticsHandler: analyticsHandler,
|
||||
StatsHandler: statsHandler,
|
||||
StatisticsService: statisticsService,
|
||||
QueueHandler: queueHandler,
|
||||
SystemSettingsHandler: systemSettingsHandler,
|
||||
ProcessingIssuesHandler: processingIssuesHandler,
|
||||
@@ -548,6 +577,10 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
// the main binary wires database.NewSettingsRegistry(queries) —
|
||||
// the test harness must too or route registration nil-panics.
|
||||
Settings: database.NewSettingsRegistry(queries),
|
||||
// main.go:246 passes the shared LibraryService here; omitting it
|
||||
// hands the kobo/kepub CFI converter a typed-nil (kobo markup
|
||||
// panic-recovered as 500s in TestProgressWeb_KoboIntegration).
|
||||
LibraryService: libraryService,
|
||||
}
|
||||
|
||||
router.RegisterRoutes(routerConfig)
|
||||
@@ -623,9 +656,41 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
require.NoError(t, cerr, "Failed to create admin test user")
|
||||
adminUUID, err = uuid.FromBytes(adminUser.ID.Bytes[:])
|
||||
require.NoError(t, err, "Failed to parse admin user UUID")
|
||||
// Default collections are created exactly once per user; the
|
||||
// preserved dev admin already has its set from the first run.
|
||||
}
|
||||
// The canonical system-collection set is ensured on EVERY run: the
|
||||
// helper is idempotent by (user, query_type) and heals tombstoned or
|
||||
// renamed rows, so suite runs can't accumulate naming-drift copies.
|
||||
createDefaultCollectionsForUser(t, queries, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||
|
||||
// Delete test-created COLLECTIONS (names containing "test"): without
|
||||
// this, every suite run leaves its collections behind and the next
|
||||
// run's creates die on UNIQUE(user_id, name) — the "collections
|
||||
// 500-panic" rot. System collections are skipped (permanent rows).
|
||||
// Same convention as the library pass: names containing "test".
|
||||
adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||
if cerr == nil {
|
||||
for _, coll := range adminColls {
|
||||
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
|
||||
queries.DeleteCollection(ctx, coll.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Same passes for test DEVICES (identifier/name containing "test")
|
||||
// and test SAVED FILTERS (name containing "test"): both accumulated
|
||||
// across runs and poisoned later runs (duplicate identifiers, 409s,
|
||||
// non-empty "initially empty" assertions).
|
||||
_, derr := dbPool.Exec(ctx,
|
||||
"DELETE FROM devices WHERE user_id = $1 AND (device_identifier ILIKE '%test%' OR device_name ILIKE '%test%')",
|
||||
adminUUID)
|
||||
if derr != nil {
|
||||
t.Logf("device cleanup failed: %v", derr)
|
||||
}
|
||||
_, ferr := dbPool.Exec(ctx,
|
||||
"DELETE FROM saved_filters WHERE user_id = $1 AND name ILIKE '%test%'",
|
||||
adminUUID)
|
||||
if ferr != nil {
|
||||
t.Logf("saved-filter cleanup failed: %v", ferr)
|
||||
}
|
||||
|
||||
// Create fresh regular test user
|
||||
@@ -636,6 +701,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
FirstName: pgtype.Text{String: "Regular", Valid: true},
|
||||
LastName: pgtype.Text{String: "User", Valid: true},
|
||||
Role: "user",
|
||||
Timezone: pgtype.Text{String: "UTC", Valid: true},
|
||||
})
|
||||
require.NoError(t, err, "Failed to create regular test user")
|
||||
|
||||
@@ -676,6 +742,17 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
}
|
||||
}
|
||||
|
||||
// Same pass for test-created COLLECTIONS (the "collections
|
||||
// 500-panic" rot: they lingered forever and poisoned later runs).
|
||||
adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||
if cerr == nil {
|
||||
for _, coll := range adminColls {
|
||||
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
|
||||
queries.DeleteCollection(ctx, coll.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := setup.Close(); err != nil {
|
||||
t.Errorf("Failed to cleanup test server: %v", err)
|
||||
}
|
||||
@@ -723,6 +800,26 @@ func getTestUserID(t *testing.T, db *database.Queries) uuid.UUID {
|
||||
return userUUID
|
||||
}
|
||||
|
||||
// createLibraryDirect inserts a library owned by the given user WITHOUT a
|
||||
// nested test-server setup: a second setupTestServer bootstraps away the
|
||||
// first setup's users, and the ghost-session guard rightly rejects the
|
||||
// outer test's tokens from then on.
|
||||
func createLibraryDirect(t *testing.T, db *database.Queries, ownerID uuid.UUID, name, libraryType string) string {
|
||||
ctx := context.Background()
|
||||
libraryTypeRow, err := db.GetLibraryTypeByName(ctx, libraryType)
|
||||
require.NoError(t, err, "Should find library type")
|
||||
library, err := db.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||
Name: name,
|
||||
Description: pgtype.Text{String: "Test library description", Valid: true},
|
||||
LibraryTypeID: libraryTypeRow.ID,
|
||||
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(ownerID), Valid: true},
|
||||
})
|
||||
require.NoError(t, err, "Should create library")
|
||||
libraryUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
|
||||
require.NoError(t, err, "Should parse library ID")
|
||||
return libraryUUID.String()
|
||||
}
|
||||
|
||||
func getRegularUserID(t *testing.T, db *database.Queries) uuid.UUID {
|
||||
ctx := context.Background()
|
||||
user, err := db.GetUserByEmail(ctx, "testregularuser@tests.bookhoard.internal")
|
||||
|
||||
@@ -8,22 +8,65 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// ensureSingleAdmin makes the "last admin" precondition deterministic in
|
||||
// the shared test DB: only the preserved testuser admin remains. Other
|
||||
// suites may have promoted or renamed users; the last-admin semantics
|
||||
// under test require a true singleton.
|
||||
func ensureSingleAdmin(t *testing.T, setup *TestServerSetup) {
|
||||
ctx := context.Background()
|
||||
_, err := setup.DBPool.Exec(ctx,
|
||||
"DELETE FROM users WHERE role = 'admin' AND email != 'testuser@tests.bookhoard.internal'")
|
||||
require.NoError(t, err, "Failed to prune extra admins")
|
||||
|
||||
if _, err := setup.DB.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal"); err != nil {
|
||||
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
|
||||
_, cerr := setup.DB.CreateUser(ctx, database.CreateUserParams{
|
||||
Email: "testuser@tests.bookhoard.internal",
|
||||
Username: "testuser",
|
||||
PasswordHash: passwordHash,
|
||||
Role: "admin",
|
||||
Timezone: pgtype.Text{String: "UTC", Valid: true},
|
||||
})
|
||||
require.NoError(t, cerr, "Failed to restore the shared admin")
|
||||
}
|
||||
|
||||
users, _ := setup.DB.ListUsers(ctx)
|
||||
adminCount := 0
|
||||
for _, u := range users {
|
||||
if u.Role == "admin" {
|
||||
adminCount++
|
||||
}
|
||||
}
|
||||
require.Equal(t, 1, adminCount, "precondition: exactly one admin")
|
||||
}
|
||||
|
||||
func TestUpdateProfileSelfEdit(t *testing.T) {
|
||||
t.Run("PUT /api/auth/profile - Update all fields successfully", func(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
_ = createTestUserOnce(t, setup.DB)
|
||||
token := setup.Token
|
||||
// Self-edit a DEDICATED user, never the shared admin: renaming
|
||||
// testuser here leaked a second admin row into the shared DB (the
|
||||
// renamed email escapes the harness's preserve-list cleanup) and
|
||||
// broke the last-admin tests downstream.
|
||||
selfUser := createRegularUserOnce(t, setup.DB)
|
||||
token := loginUserWithCredentials(t, setup.Server, selfUser.Email, selfUser.Password)
|
||||
|
||||
// Unique rename target: a fixed name collides with stale rows from
|
||||
// earlier runs of this very test (409 username conflict).
|
||||
suffix := uuid.New().String()[:8]
|
||||
newUsername := "updateduser-" + suffix
|
||||
updateData := map[string]interface{}{
|
||||
"username": "updateduser",
|
||||
"email": "updated@tests.bookhoard.internal",
|
||||
"username": newUsername,
|
||||
"email": "updated-" + suffix + "@tests.bookhoard.internal",
|
||||
"first_name": "Updated",
|
||||
"last_name": "Name",
|
||||
"theme": "dracula",
|
||||
@@ -39,9 +82,9 @@ func TestUpdateProfileSelfEdit(t *testing.T) {
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
user, err := setup.DB.GetUserByUsername(context.Background(), "updateduser")
|
||||
user, err := setup.DB.GetUserByUsername(context.Background(), newUsername)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "updated@tests.bookhoard.internal", user.Email)
|
||||
assert.Equal(t, "updated-"+suffix+"@tests.bookhoard.internal", user.Email)
|
||||
assert.Equal(t, "Updated", user.FirstName.String)
|
||||
assert.Equal(t, "Name", user.LastName.String)
|
||||
assert.Equal(t, "dracula", user.Theme.String)
|
||||
@@ -215,6 +258,10 @@ func TestUpdateProfileAdminMode(t *testing.T) {
|
||||
t.Run("PUT /api/auth/profile/:id - Try to demote last admin", func(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
// Deterministic singleton-admin precondition (shared DB may carry
|
||||
// promoted/renamed admin rows from other suites).
|
||||
ensureSingleAdmin(t, setup)
|
||||
|
||||
// Delete the regular user so we have only 1 admin (testuser)
|
||||
regularUser := createRegularUserOnce(t, setup.DB)
|
||||
err := setup.DB.DeleteUser(context.Background(), uuidToPGType(regularUser.ID))
|
||||
@@ -435,6 +482,7 @@ func TestDeleteUserConsolidated(t *testing.T) {
|
||||
t.Run("DELETE /api/auth/profile - Last admin tries self-delete", func(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
ensureSingleAdmin(t, setup)
|
||||
_ = createTestUserOnce(t, setup.DB)
|
||||
token := setup.Token
|
||||
|
||||
@@ -471,6 +519,7 @@ func TestDeleteUserConsolidated(t *testing.T) {
|
||||
t.Run("DELETE /api/auth/profile/:id - Admin tries delete last admin", func(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
ensureSingleAdmin(t, setup)
|
||||
_ = createTestUserOnce(t, setup.DB)
|
||||
token := setup.Token
|
||||
|
||||
|
||||
@@ -240,9 +240,11 @@ func TestWebSocketUserScopedBroadcast(t *testing.T) {
|
||||
// Use pre-created admin user (setup.Token) and regular user (setup.RegularToken)
|
||||
// Both users already created by setupTestServer()
|
||||
|
||||
// Create a collection for admin user
|
||||
// Create a collection for admin user. Unique name: the shared DB keeps
|
||||
// non-"test"-named collections across runs, and UNIQUE(user_id, name)
|
||||
// turns a fixed name into a 409 on the second run.
|
||||
collectionReq := map[string]interface{}{
|
||||
"name": "Admin Collection",
|
||||
"name": "Admin Collection " + uuid.New().String()[:8],
|
||||
"description": "Test collection for WebSocket test",
|
||||
}
|
||||
collectionBody, _ := json.Marshal(collectionReq)
|
||||
|
||||
@@ -68,7 +68,7 @@ INSERT INTO system_settings (setting_key, setting_value, description, setting_ty
|
||||
('password_require_lower', 'true', 'Require at least one lowercase letter (a-z)', 'bool', NULL, NULL, FALSE, 'security'),
|
||||
('password_require_number', 'true', 'Require at least one number (0-9)', 'bool', NULL, NULL, FALSE, 'security'),
|
||||
('password_require_special', 'true', 'Require at least one special character', 'bool', NULL, NULL, FALSE, 'security'),
|
||||
('registration_enabled', 'true', 'Allow users to create their own accounts (admins can always create accounts)', 'bool', NULL, NULL, FALSE, 'security'),
|
||||
('registration_enabled', 'false', 'Allow users to create their own accounts (admins can always create accounts)', 'bool', NULL, NULL, FALSE, 'security'),
|
||||
-- security / auth (restart required)
|
||||
('auth_rate_limit_per_min', '10', 'Global auth API rate limit (requests per minute)', 'int', '1', '10000', TRUE, 'security'),
|
||||
('login_max_attempts', '5', 'Failed login attempts before lockout', 'int', '1', '100', TRUE, 'security'),
|
||||
@@ -1135,9 +1135,15 @@ CREATE TABLE IF NOT EXISTS collections (
|
||||
priority INT DEFAULT 100,
|
||||
is_system_collection BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
UNIQUE(user_id, name)
|
||||
);
|
||||
|
||||
-- System collections (the 5 dashboard sections) are permanent: "delete"
|
||||
-- tombstones the row (deleted_at) and restore un-tombstones it in place —
|
||||
-- the row id (and everything referencing it) never churn.
|
||||
ALTER TABLE collections ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id);
|
||||
|
||||
-- Index for dashboard queries
|
||||
@@ -1160,6 +1166,20 @@ CREATE TABLE IF NOT EXISTS user_dashboard_preferences (
|
||||
-- Index for fast lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id);
|
||||
|
||||
-- Global (all-libraries) dashboard preferences: exactly one row per user.
|
||||
-- Backs the dashboard's "All Libraries" view (library_id NULL convention
|
||||
-- rejected: the upsert's ON CONFLICT cannot dedupe NULLs and intent would
|
||||
-- be invisible — a dedicated table makes the scope structural). System
|
||||
-- and user collections are user-scoped, so collection_order/
|
||||
-- hidden_collections IDs are library-independent and valid in this scope.
|
||||
CREATE TABLE IF NOT EXISTS user_dashboard_preferences_global (
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
hidden_collections TEXT[] DEFAULT '{}',
|
||||
collection_order TEXT[] DEFAULT '{}',
|
||||
items_per_section INT DEFAULT 20,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create collection_items table (which books belong to each collection)
|
||||
CREATE TABLE IF NOT EXISTS collection_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
@@ -14,7 +14,7 @@ Welcome to the Bookhoard contributing documentation. This section contains guide
|
||||
|
||||
## 📋 Project Guidelines
|
||||
|
||||
- **[PROJECT_GUIDELINES.md](../PROJECT_GUIDELINES.md)** - Development rules and standards
|
||||
- **[AGENTS.md](../../AGENTS.md)** - Agent/development rules
|
||||
- Critical prohibitions
|
||||
- Mandatory requirements
|
||||
- Error recovery protocol
|
||||
|
||||
@@ -445,7 +445,7 @@ DEBUG=true
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
- [Project Guidelines](../../PROJECT_GUIDELINES.md) - Development rules and standards
|
||||
- [AGENTS.md](../../AGENTS.md) - Agent/development rules
|
||||
- [API Reference](../API_REFERENCE.md) - Complete API documentation
|
||||
- [Troubleshooting](../TROUBLESHOOTING.md) - Deployment issues
|
||||
- [Go Documentation](https://go.dev/doc/)
|
||||
|
||||
@@ -73,6 +73,12 @@ Browse individual endpoint documentation with interactive API Explorer:
|
||||
|
||||
## 🛠 Implementation Guides
|
||||
|
||||
### Library Visibility
|
||||
|
||||
- **[Library Visibility](library-visibility.md)** - Per-user library
|
||||
visibility: the family-lens rule. New media endpoints MUST thread the
|
||||
visible-library set (helpers, SQL pattern, series per-library).
|
||||
|
||||
### Calibre Integration
|
||||
|
||||
- **[Calibre OPF Implementation](../development/calibre-opf-implementation.md)** - Technical implementation details
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Library Visibility (family-lens rule)
|
||||
|
||||
Every endpoint that returns media metadata or content must respect
|
||||
per-user **library visibility**: the `library_visibility` table maps
|
||||
`(user_id, library_id, is_visible)` and defaults to **ALLOW** — every
|
||||
user sees every library unless an admin explicitly hides one from them.
|
||||
The point of the system is the family case: an admin hides the adult
|
||||
library from a child's account, and no trace of that library (titles,
|
||||
covers, series, page images) may reach the child's account.
|
||||
|
||||
## The rule for new endpoints
|
||||
|
||||
Any endpoint that lists, filters, or details media/series/books:
|
||||
|
||||
1. Filter by the requesting user's visible-library set.
|
||||
2. Reject an explicit `library_id` outside that set with **403** —
|
||||
never silently ignore or fall back.
|
||||
3. Content endpoints (file bytes, page images) gate per item, like
|
||||
`ReaderHandler.ensureItemVisible`.
|
||||
|
||||
If you forget, hidden libraries leak. This exact leak shipped: the
|
||||
series endpoints were name-only across ALL libraries and the PDF
|
||||
thumbnail endpoint served page images of hidden books (fixed 2026-10-13,
|
||||
bookhoard `431fec0`).
|
||||
|
||||
## Helpers — do not hand-roll
|
||||
|
||||
- `(*MediaHandler).visibleLibrarySet` /
|
||||
`(*SeriesHandler).visibleLibrarySet` — returns a membership map plus
|
||||
the id slice (map for param checks, slice for SQL `ANY()`).
|
||||
- `router.visibleLibraryIDsFor` — same for SSR routes.
|
||||
- `resolveLibrary` (router/helpers.go) only honors a requested
|
||||
`library_id` that is inside the caller's visible set.
|
||||
- SQL pattern (see `ListMediaItems`, `GetDistinctSeries`,
|
||||
`SearchMediaItems`):
|
||||
|
||||
```sql
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL
|
||||
OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
```
|
||||
|
||||
## Known-safe surfaces
|
||||
|
||||
- OPDS device catalogs filter by the device owner's visible libraries.
|
||||
- Search (`SearchMediaItems`) is visibility-scoped.
|
||||
- File download, reader page render, and page content gate per item.
|
||||
|
||||
## Series are per-library
|
||||
|
||||
Series are grouped by `(library_id, series)` — the same series name in
|
||||
two libraries is two entries (manga adaptations, LN splits). Do not
|
||||
re-merge them cross-library. `/api/series/books` REQUIRES a visible
|
||||
`library_id`; `/api/series` without one aggregates only the caller's
|
||||
visible libraries.
|
||||
+2
-2
@@ -63,7 +63,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
||||
**[Contributing Portal](contributing/contributing.md)** - Development workflow
|
||||
|
||||
- [Development Guide](contributing/development.md) - Architecture, setup, testing
|
||||
- [../PROJECT_GUIDELINES.md](../PROJECT_GUIDELINES.md) - Development rules and standards
|
||||
- [../../AGENTS.md](../../AGENTS.md) - Development rules and standards
|
||||
|
||||
---
|
||||
|
||||
@@ -143,7 +143,7 @@ When adding new features:
|
||||
3. **Backend changes** → Update [Development Guide](contributing/development.md)
|
||||
4. **Deployment changes** → Update [Operations Portal](operations/operations.md)
|
||||
|
||||
Keep [../PROJECT_GUIDELINES.md](../PROJECT_GUIDELINES.md) in mind for documentation standards.
|
||||
Keep [../../AGENTS.md](../../AGENTS.md) in mind for documentation standards.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.31.0
|
||||
|
||||
package database
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.31.0
|
||||
|
||||
package database
|
||||
|
||||
@@ -31,6 +31,7 @@ type Collections struct {
|
||||
Priority pgtype.Int4 `db:"priority" json:"priority"`
|
||||
IsSystemCollection pgtype.Bool `db:"is_system_collection" json:"is_system_collection"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
||||
}
|
||||
|
||||
type DeviceCatalogs struct {
|
||||
@@ -538,6 +539,14 @@ type UserDashboardPreferences struct {
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
type UserDashboardPreferencesGlobal struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
HiddenCollections []string `db:"hidden_collections" json:"hidden_collections"`
|
||||
CollectionOrder []string `db:"collection_order" json:"collection_order"`
|
||||
ItemsPerSection pgtype.Int4 `db:"items_per_section" json:"items_per_section"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
type Users struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Email string `db:"email" json:"email"`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.31.0
|
||||
|
||||
package database
|
||||
|
||||
@@ -30,6 +30,7 @@ type Querier interface {
|
||||
// Cleanup expired OPDS tokens
|
||||
CleanupExpiredOpdsTokens(ctx context.Context) error
|
||||
CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error
|
||||
ClearCollectionItems(ctx context.Context, collectionID pgtype.UUID) error
|
||||
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
|
||||
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
|
||||
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
|
||||
@@ -41,6 +42,10 @@ type Querier interface {
|
||||
ClearMediaItemMetadataOverrides(ctx context.Context, id pgtype.UUID) error
|
||||
CountAdmins(ctx context.Context) (int64, error)
|
||||
CountArchivedMediaItems(ctx context.Context) (int64, error)
|
||||
// Live item count for a STATIC collection (rule-free cards on the
|
||||
// collections page); same live filter as GetCollectionItems, plus the
|
||||
// future-hybrid excluded exclusion and an optional library scope.
|
||||
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
|
||||
// Count unlinked books for a device
|
||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
||||
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||
@@ -79,6 +84,11 @@ type Querier interface {
|
||||
// MEDIA ITEM FORMATS QUERIES
|
||||
// Create media item format
|
||||
CreateMediaItemFormat(ctx context.Context, arg CreateMediaItemFormatParams) (MediaItemFormats, error)
|
||||
// API create path: race-safe create-or-detect. On a duplicate
|
||||
// (library_id, file_path) this inserts NOTHING and returns no row
|
||||
// (pgx.ErrNoRows) — the handler then answers 200 with the existing row
|
||||
// and a duplicate flag instead of pretending it created something.
|
||||
CreateMediaItemIfNew(ctx context.Context, arg CreateMediaItemIfNewParams) (MediaItems, error)
|
||||
// Media Notes queries
|
||||
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
|
||||
CreateMediaNoteFull(ctx context.Context, arg CreateMediaNoteFullParams) (MediaNotes, error)
|
||||
@@ -125,12 +135,12 @@ type Querier interface {
|
||||
DeleteKoboEntitlement(ctx context.Context, arg DeleteKoboEntitlementParams) error
|
||||
DeleteLibrary(ctx context.Context, id pgtype.UUID) error
|
||||
DeleteLibraryFolder(ctx context.Context, arg DeleteLibraryFolderParams) (LibraryFolders, error)
|
||||
DeleteMediaBookmark(ctx context.Context, id pgtype.UUID) error
|
||||
DeleteMediaHighlight(ctx context.Context, id pgtype.UUID) error
|
||||
DeleteMediaBookmark(ctx context.Context, arg DeleteMediaBookmarkParams) error
|
||||
DeleteMediaHighlight(ctx context.Context, arg DeleteMediaHighlightParams) error
|
||||
DeleteMediaItem(ctx context.Context, id pgtype.UUID) error
|
||||
// Delete media item format
|
||||
DeleteMediaItemFormat(ctx context.Context, id pgtype.UUID) error
|
||||
DeleteMediaNote(ctx context.Context, id pgtype.UUID) error
|
||||
DeleteMediaNote(ctx context.Context, arg DeleteMediaNoteParams) error
|
||||
DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error
|
||||
DeleteProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error)
|
||||
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
|
||||
@@ -143,6 +153,10 @@ type Querier interface {
|
||||
DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
||||
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
||||
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
|
||||
// Device existence probe for capture: the web/app progress paths
|
||||
// historically stuff the user id into DeviceID (it only fed a broadcast
|
||||
// label before stats); a capture row must only reference a REAL device.
|
||||
DeviceExistsForUser(ctx context.Context, arg DeviceExistsForUserParams) (bool, error)
|
||||
// Find content-duplicate groups (same library + SHA-256, more than one row)
|
||||
FindHashConflictGroups(ctx context.Context) ([]FindHashConflictGroupsRow, error)
|
||||
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
|
||||
@@ -155,6 +169,8 @@ type Querier interface {
|
||||
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
|
||||
GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error)
|
||||
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
|
||||
// Lifetime session rows for ONE book (the book-detail stats card).
|
||||
GetBookReadingSessions(ctx context.Context, arg GetBookReadingSessionsParams) ([]GetBookReadingSessionsRow, error)
|
||||
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
|
||||
// Get collection
|
||||
GetCollection(ctx context.Context, id pgtype.UUID) (Collections, error)
|
||||
@@ -174,6 +190,10 @@ type Querier interface {
|
||||
// CAROUSEL-STYLE DASHBOARD
|
||||
// ============================================
|
||||
// Dashboard preferences queries
|
||||
// The handler routes by scope: a Valid library_id targets the per-library
|
||||
// table below; an absent one targets user_dashboard_preferences_global
|
||||
// (the "All Libraries" view). Never pass NULL here — the loose NULL match
|
||||
// was removed because it returned an arbitrary library's row.
|
||||
GetDashboardPreferences(ctx context.Context, arg GetDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||
GetDevice(ctx context.Context, id pgtype.UUID) (Devices, error)
|
||||
GetDeviceByAuthToken(ctx context.Context, authToken string) (Devices, error)
|
||||
@@ -195,11 +215,16 @@ type Querier interface {
|
||||
// Get device shelf mappings
|
||||
GetDeviceShelfMappings(ctx context.Context, deviceID pgtype.UUID) ([]GetDeviceShelfMappingsRow, error)
|
||||
GetDictionaryEntry(ctx context.Context, word string) (DictionaryCache, error)
|
||||
// Series are grouped PER LIBRARY: the same series name in two libraries
|
||||
// (novels vs manga adaptations, LN splits) stays two entries. Visible
|
||||
// filter enforces per-user library visibility at the source.
|
||||
GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error)
|
||||
GetDistinctSeriesCount(ctx context.Context, libraryID pgtype.UUID) (int32, error)
|
||||
GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error)
|
||||
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
|
||||
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
|
||||
GetFirstAdminExclude(ctx context.Context, id pgtype.UUID) (GetFirstAdminExcludeRow, error)
|
||||
// Global (all-libraries) scope: exactly one row per user.
|
||||
GetGlobalDashboardPreferences(ctx context.Context, userID pgtype.UUID) (UserDashboardPreferencesGlobal, error)
|
||||
GetHashConflict(ctx context.Context, id pgtype.UUID) (HashConflicts, error)
|
||||
GetKoboEntitlementByContentId(ctx context.Context, arg GetKoboEntitlementByContentIdParams) (GetKoboEntitlementByContentIdRow, error)
|
||||
GetKoboEntitlementByEntitlementId(ctx context.Context, arg GetKoboEntitlementByEntitlementIdParams) (GetKoboEntitlementByEntitlementIdRow, error)
|
||||
@@ -243,6 +268,7 @@ type Querier interface {
|
||||
// ============================================
|
||||
GetMediaItemByFilePathForSync(ctx context.Context, filePath string) (MediaItems, error)
|
||||
GetMediaItemByKoboContentId(ctx context.Context, koboContentID pgtype.Text) (MediaItems, error)
|
||||
GetMediaItemByLibraryAndPath(ctx context.Context, arg GetMediaItemByLibraryAndPathParams) (MediaItems, error)
|
||||
// Get media item by OPF identifier
|
||||
GetMediaItemByOPFIdentifier(ctx context.Context, opfIdentifier pgtype.Text) (MediaItems, error)
|
||||
// Get media item by OPF UUID
|
||||
@@ -293,12 +319,21 @@ type Querier interface {
|
||||
GetRefreshToken(ctx context.Context, token pgtype.UUID) (GetRefreshTokenRow, error)
|
||||
GetSavedFilterByID(ctx context.Context, arg GetSavedFilterByIDParams) (SavedFilters, error)
|
||||
GetSavedFilters(ctx context.Context, arg GetSavedFiltersParams) ([]SavedFilters, error)
|
||||
GetSeriesBooks(ctx context.Context, series pgtype.Text) ([]MediaItems, error)
|
||||
// Scoped to ONE library: same-name series in different libraries are
|
||||
// different stories (manga adaptations, LN splits). Callers must pass a
|
||||
// library_id and verify the requester can see that library.
|
||||
GetSeriesBooks(ctx context.Context, arg GetSeriesBooksParams) ([]MediaItems, error)
|
||||
GetSeriesCovers(ctx context.Context, arg GetSeriesCoversParams) ([]GetSeriesCoversRow, error)
|
||||
GetStuckSyncQueueItems(ctx context.Context) ([]SyncQueue, error)
|
||||
GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, error)
|
||||
GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQueue, error)
|
||||
GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) (GetSyncQueueStatsRow, error)
|
||||
// System collections are permanent: identity is (user_id, query_type).
|
||||
// Restore/reset operate IN PLACE (the row id — and everything referencing
|
||||
// it: shelf mappings, prefs order, links — never churns). Deleted sections
|
||||
// are TOMBSTONED (deleted_at) and stay invisible until restored.
|
||||
// Unfiltered by design: restore/seed must resolve tombstoned rows too.
|
||||
GetSystemCollectionByQueryType(ctx context.Context, arg GetSystemCollectionByQueryTypeParams) (Collections, error)
|
||||
// Dashboard collections queries
|
||||
GetSystemCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, error)
|
||||
// SYSTEM CONFIG QUERIES
|
||||
@@ -309,6 +344,8 @@ type Querier interface {
|
||||
GetSystemSettingFull(ctx context.Context, settingKey string) (SystemSettings, error)
|
||||
GetSystemTimezone(ctx context.Context) (string, error)
|
||||
GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error)
|
||||
// Most-read books for a range (minutes), for the top list.
|
||||
GetTopBooksByMinutes(ctx context.Context, arg GetTopBooksByMinutesParams) ([]GetTopBooksByMinutesRow, error)
|
||||
// Get universal progress for a book
|
||||
GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error)
|
||||
// Get unlinked book by ContentId
|
||||
@@ -322,17 +359,42 @@ type Querier interface {
|
||||
GetUserByEmailOrUsername(ctx context.Context, email string) (GetUserByEmailOrUsernameRow, error)
|
||||
GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error)
|
||||
GetUserCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, error)
|
||||
// Per-device reading breakdown for a range. LEFT JOIN so pushes that
|
||||
// carry no device (web reader until it identifies) still appear as one
|
||||
// "unattributed" bucket — device minutes must reconcile with the totals.
|
||||
GetUserDeviceBreakdown(ctx context.Context, arg GetUserDeviceBreakdownParams) ([]GetUserDeviceBreakdownRow, error)
|
||||
// Get user device usage statistics
|
||||
GetUserDeviceUsage(ctx context.Context, userID pgtype.UUID) ([]GetUserDeviceUsageRow, error)
|
||||
GetUserForLogin(ctx context.Context, email string) (GetUserForLoginRow, error)
|
||||
// Per-library aggregation for the same range.
|
||||
GetUserLibraryBreakdown(ctx context.Context, arg GetUserLibraryBreakdownParams) ([]GetUserLibraryBreakdownRow, error)
|
||||
GetUserMediaItemsForSync(ctx context.Context, userID pgtype.UUID) ([]GetUserMediaItemsForSyncRow, error)
|
||||
GetUserPasswordHash(ctx context.Context, id pgtype.UUID) (string, error)
|
||||
GetUserProgressForBooks(ctx context.Context, arg GetUserProgressForBooksParams) ([]GetUserProgressForBooksRow, error)
|
||||
// Distinct ACTIVE days (any captured session) bucketed in the user's
|
||||
// timezone — the streak calendar. Scoped to a long look-back so the
|
||||
// "longest streak" and a current streak longer than the selected range
|
||||
// are both real.
|
||||
GetUserReadingActiveDays(ctx context.Context, arg GetUserReadingActiveDaysParams) ([]string, error)
|
||||
// Analytics queries
|
||||
// Get user reading history for analytics
|
||||
GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error)
|
||||
// STATISTICS (reading sessions) — time spent, pages, streaks, breakdowns.
|
||||
// Every stats query scopes to the requesting user's VISIBLE libraries
|
||||
// (family lens at the source) and single scan of reading_history.
|
||||
// Full session rows for a range (summary math runs in Go: tz buckets,
|
||||
// streaks, finished-book detection). The bucket/range key is the session
|
||||
// END instant (the push time; NULL falls back to created_at) — the same
|
||||
// value the capture stamps every write with.
|
||||
GetUserReadingSessionsRange(ctx context.Context, arg GetUserReadingSessionsRangeParams) ([]GetUserReadingSessionsRangeRow, error)
|
||||
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
||||
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
|
||||
// Smart ("dynamic") collections: rules are evaluated LIVE at render, so
|
||||
// membership comes from the candidate book set instead of collection_items.
|
||||
// The set is scoped to the requesting user's visible libraries (family
|
||||
// lens enforced at the source) and live items only — the same filters the
|
||||
// static collection paths apply.
|
||||
GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error)
|
||||
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
|
||||
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
|
||||
// Check if book is in collection
|
||||
@@ -419,6 +481,9 @@ type Querier interface {
|
||||
RestoreMediaBookmarkByID(ctx context.Context, arg RestoreMediaBookmarkByIDParams) (int64, error)
|
||||
RestoreMediaHighlightByID(ctx context.Context, arg RestoreMediaHighlightByIDParams) (int64, error)
|
||||
RestoreMediaNoteByID(ctx context.Context, arg RestoreMediaNoteByIDParams) (int64, error)
|
||||
// Full reset, in place: canonical metadata, back on the dashboard,
|
||||
// un-tombstoned. Item assignments are cleared by the caller ("full").
|
||||
RestoreSystemCollectionRow(ctx context.Context, arg RestoreSystemCollectionRowParams) (Collections, error)
|
||||
RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error
|
||||
RevokeDevice(ctx context.Context, id pgtype.UUID) error
|
||||
// Revoke OPDS token
|
||||
@@ -438,13 +503,17 @@ type Querier interface {
|
||||
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error)
|
||||
SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error
|
||||
TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error
|
||||
TombstoneMediaBookmarkByID(ctx context.Context, id pgtype.UUID) error
|
||||
TombstoneMediaBookmarkByID(ctx context.Context, arg TombstoneMediaBookmarkByIDParams) error
|
||||
TombstoneMediaHighlightByDedupKey(ctx context.Context, arg TombstoneMediaHighlightByDedupKeyParams) error
|
||||
TombstoneMediaHighlightByID(ctx context.Context, id pgtype.UUID) error
|
||||
TombstoneMediaHighlightByID(ctx context.Context, arg TombstoneMediaHighlightByIDParams) error
|
||||
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
|
||||
TombstoneMediaNoteByID(ctx context.Context, id pgtype.UUID) error
|
||||
TombstoneMediaNoteByID(ctx context.Context, arg TombstoneMediaNoteByIDParams) error
|
||||
TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error
|
||||
// Update collection
|
||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
||||
// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
|
||||
// of the JSONB without touching metadata stale clients did not send).
|
||||
UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error)
|
||||
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||
UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error)
|
||||
UpdateDeviceAuthToken(ctx context.Context, arg UpdateDeviceAuthTokenParams) (Devices, error)
|
||||
@@ -503,6 +572,7 @@ type Querier interface {
|
||||
UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error
|
||||
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
|
||||
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||
UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error)
|
||||
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
|
||||
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
|
||||
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
|
||||
|
||||
+1190
-53
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
-- name: CreateUser :one
|
||||
INSERT INTO users (email, username, password_hash, first_name, last_name, theme, role)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, email, username, theme, first_name, last_name, role, created_at, updated_at;
|
||||
INSERT INTO users (email, username, password_hash, first_name, last_name, theme, role, timezone)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, email, username, theme, first_name, last_name, role, timezone, created_at, updated_at;
|
||||
|
||||
-- name: GetUserByEmail :one
|
||||
SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE email = $1;
|
||||
@@ -154,6 +154,19 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $
|
||||
ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW()
|
||||
RETURNING *;
|
||||
|
||||
-- name: CreateMediaItemIfNew :one
|
||||
-- API create path: race-safe create-or-detect. On a duplicate
|
||||
-- (library_id, file_path) this inserts NOTHING and returns no row
|
||||
-- (pgx.ErrNoRows) — the handler then answers 200 with the existing row
|
||||
-- and a duplicate flag instead of pretending it created something.
|
||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44)
|
||||
ON CONFLICT (library_id, file_path) DO NOTHING
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetMediaItemByLibraryAndPath :one
|
||||
SELECT * FROM media_items WHERE library_id = $1 AND file_path = $2;
|
||||
|
||||
-- name: GetMediaItem :one
|
||||
SELECT * FROM media_items WHERE id = $1;
|
||||
|
||||
@@ -164,6 +177,7 @@ JOIN libraries l ON mi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
WHERE mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: ListMediaItemsByLibrary :many
|
||||
@@ -857,11 +871,11 @@ UPDATE media_notes SET
|
||||
content = $2,
|
||||
position = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
WHERE id = $1 AND user_id = $4
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteMediaNote :exec
|
||||
DELETE FROM media_notes WHERE id = $1;
|
||||
DELETE FROM media_notes WHERE id = $1 AND user_id = $2;
|
||||
|
||||
-- Media Highlights queries
|
||||
-- name: CreateMediaHighlight :one
|
||||
@@ -883,11 +897,11 @@ UPDATE media_highlights SET
|
||||
color = $5,
|
||||
note_id = $6,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
WHERE id = $1 AND user_id = $7
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteMediaHighlight :exec
|
||||
DELETE FROM media_highlights WHERE id = $1;
|
||||
DELETE FROM media_highlights WHERE id = $1 AND user_id = $2;
|
||||
|
||||
-- ============================================
|
||||
-- ANNOTATION SYNC QUERIES (highlights)
|
||||
@@ -930,7 +944,7 @@ UPDATE media_highlights SET
|
||||
updated_at = NOW(),
|
||||
deleted = FALSE,
|
||||
deleted_at = NULL
|
||||
WHERE id = $1
|
||||
WHERE id = $1 AND user_id = $15
|
||||
RETURNING *;
|
||||
|
||||
-- name: TombstoneMediaHighlightByDedupKey :exec
|
||||
@@ -945,7 +959,7 @@ UPDATE media_highlights SET
|
||||
deleted = TRUE,
|
||||
deleted_at = NOW(),
|
||||
last_modified_at = NOW()
|
||||
WHERE id = $1;
|
||||
WHERE id = $1 AND user_id = $2;
|
||||
|
||||
-- name: PurgeExpiredHighlightTombstones :exec
|
||||
DELETE FROM media_highlights WHERE deleted = TRUE AND deleted_at < $1;
|
||||
@@ -987,7 +1001,7 @@ UPDATE media_notes SET
|
||||
updated_at = NOW(),
|
||||
deleted = FALSE,
|
||||
deleted_at = NULL
|
||||
WHERE id = $1
|
||||
WHERE id = $1 AND user_id = $13
|
||||
RETURNING *;
|
||||
|
||||
-- name: TombstoneMediaNoteByDedupKey :exec
|
||||
@@ -1002,7 +1016,7 @@ UPDATE media_notes SET
|
||||
deleted = TRUE,
|
||||
deleted_at = NOW(),
|
||||
last_modified_at = NOW()
|
||||
WHERE id = $1;
|
||||
WHERE id = $1 AND user_id = $2;
|
||||
|
||||
-- name: PurgeExpiredNoteTombstones :exec
|
||||
DELETE FROM media_notes WHERE deleted = TRUE AND deleted_at < $1;
|
||||
@@ -1045,7 +1059,7 @@ UPDATE media_bookmarks SET
|
||||
created_at = created_at,
|
||||
deleted = FALSE,
|
||||
deleted_at = NULL
|
||||
WHERE id = $1
|
||||
WHERE id = $1 AND user_id = $14
|
||||
RETURNING *;
|
||||
|
||||
-- name: TombstoneMediaBookmarkByDedupKey :exec
|
||||
@@ -1060,7 +1074,7 @@ UPDATE media_bookmarks SET
|
||||
deleted = TRUE,
|
||||
deleted_at = NOW(),
|
||||
last_modified_at = NOW()
|
||||
WHERE id = $1;
|
||||
WHERE id = $1 AND user_id = $2;
|
||||
|
||||
-- name: PurgeExpiredBookmarkTombstones :exec
|
||||
DELETE FROM media_bookmarks WHERE deleted = TRUE AND deleted_at < $1;
|
||||
@@ -1453,6 +1467,128 @@ SELECT
|
||||
ORDER BY read_count DESC
|
||||
LIMIT $2;
|
||||
|
||||
-- STATISTICS (reading sessions) — time spent, pages, streaks, breakdowns.
|
||||
-- Every stats query scopes to the requesting user's VISIBLE libraries
|
||||
-- (family lens at the source) and single scan of reading_history.
|
||||
|
||||
-- Full session rows for a range (summary math runs in Go: tz buckets,
|
||||
-- streaks, finished-book detection). The bucket/range key is the session
|
||||
-- END instant (the push time; NULL falls back to created_at) — the same
|
||||
-- value the capture stamps every write with.
|
||||
-- name: GetUserReadingSessionsRange :many
|
||||
SELECT
|
||||
rh.id, rh.media_item_id, rh.device_id, rh.progress_percentage,
|
||||
rh.reading_session_start, rh.reading_session_end,
|
||||
rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at,
|
||||
mi.title, mi.author, mi.library_id, mi.cover_image_path
|
||||
FROM reading_history rh
|
||||
JOIN media_items mi ON mi.id = rh.media_item_id
|
||||
WHERE rh.user_id = sqlc.arg('user_id')
|
||||
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
|
||||
AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time')
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
ORDER BY rh.created_at ASC;
|
||||
|
||||
-- Per-library aggregation for the same range.
|
||||
-- name: GetUserLibraryBreakdown :many
|
||||
SELECT
|
||||
l.id::uuid AS library_id,
|
||||
l.name,
|
||||
COUNT(*)::int AS session_count,
|
||||
SUM(COALESCE(rh.time_spent_seconds, 0))::int AS total_seconds,
|
||||
SUM(COALESCE(rh.pages_read, 0))::int AS total_pages,
|
||||
COUNT(DISTINCT rh.media_item_id)::int AS book_count
|
||||
FROM reading_history rh
|
||||
JOIN media_items mi ON mi.id = rh.media_item_id
|
||||
JOIN libraries l ON l.id = mi.library_id
|
||||
WHERE rh.user_id = sqlc.arg('user_id')
|
||||
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
|
||||
AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time')
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
GROUP BY l.id, l.name
|
||||
ORDER BY total_seconds DESC;
|
||||
|
||||
-- Most-read books for a range (minutes), for the top list.
|
||||
-- name: GetTopBooksByMinutes :many
|
||||
SELECT
|
||||
mi.id::uuid AS media_item_id,
|
||||
mi.title,
|
||||
mi.author,
|
||||
mi.library_id,
|
||||
mi.cover_image_path,
|
||||
SUM(COALESCE(rh.time_spent_seconds, 0))::int AS total_seconds,
|
||||
SUM(COALESCE(rh.pages_read, 0))::int AS total_pages,
|
||||
COUNT(*)::int AS session_count,
|
||||
MAX(rh.created_at) AS last_read
|
||||
FROM reading_history rh
|
||||
JOIN media_items mi ON mi.id = rh.media_item_id
|
||||
WHERE rh.user_id = sqlc.arg('user_id')
|
||||
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
|
||||
AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time')
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
GROUP BY mi.id, mi.title, mi.author, mi.library_id, mi.cover_image_path
|
||||
ORDER BY total_seconds DESC
|
||||
LIMIT sqlc.arg('limit');
|
||||
|
||||
-- Lifetime session rows for ONE book (the book-detail stats card).
|
||||
-- name: GetBookReadingSessions :many
|
||||
SELECT
|
||||
rh.id, rh.device_id, rh.progress_percentage,
|
||||
rh.reading_session_start, rh.reading_session_end,
|
||||
rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at
|
||||
FROM reading_history rh
|
||||
WHERE rh.user_id = sqlc.arg('user_id')
|
||||
AND rh.media_item_id = sqlc.arg('media_item_id')
|
||||
ORDER BY rh.created_at ASC;
|
||||
|
||||
-- Distinct ACTIVE days (any captured session) bucketed in the user's
|
||||
-- timezone — the streak calendar. Scoped to a long look-back so the
|
||||
-- "longest streak" and a current streak longer than the selected range
|
||||
-- are both real.
|
||||
-- name: GetUserReadingActiveDays :many
|
||||
SELECT DISTINCT (COALESCE(rh.reading_session_end, rh.created_at) AT TIME ZONE sqlc.arg('tz')::text)::date::text AS day
|
||||
FROM reading_history rh
|
||||
JOIN media_items mi ON mi.id = rh.media_item_id
|
||||
WHERE rh.user_id = sqlc.arg('user_id')
|
||||
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
ORDER BY day ASC;
|
||||
|
||||
-- Device existence probe for capture: the web/app progress paths
|
||||
-- historically stuff the user id into DeviceID (it only fed a broadcast
|
||||
-- label before stats); a capture row must only reference a REAL device.
|
||||
-- name: DeviceExistsForUser :one
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM devices
|
||||
WHERE id = sqlc.arg('device_id') AND user_id = sqlc.arg('user_id')
|
||||
) AS has_device;
|
||||
|
||||
-- Per-device reading breakdown for a range. LEFT JOIN so pushes that
|
||||
-- carry no device (web reader until it identifies) still appear as one
|
||||
-- "unattributed" bucket — device minutes must reconcile with the totals.
|
||||
-- name: GetUserDeviceBreakdown :many
|
||||
SELECT
|
||||
d.id,
|
||||
d.device_name,
|
||||
d.device_type,
|
||||
COUNT(*)::int AS session_count,
|
||||
SUM(COALESCE(rh.time_spent_seconds, 0))::int AS total_seconds,
|
||||
SUM(COALESCE(rh.pages_read, 0))::int AS total_pages,
|
||||
COUNT(DISTINCT rh.media_item_id)::int AS book_count
|
||||
FROM reading_history rh
|
||||
JOIN media_items mi ON mi.id = rh.media_item_id
|
||||
LEFT JOIN devices d ON d.id = rh.device_id
|
||||
WHERE rh.user_id = sqlc.arg('user_id')
|
||||
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
|
||||
AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time')
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
GROUP BY d.id, d.device_name, d.device_type
|
||||
ORDER BY total_seconds DESC;
|
||||
|
||||
|
||||
|
||||
-- ============================================
|
||||
-- DEVICE MANAGEMENT & AUTH
|
||||
-- ============================================
|
||||
@@ -2151,7 +2287,7 @@ SELECT * FROM collections WHERE id = $1;
|
||||
|
||||
-- Get collections by user
|
||||
-- name: GetCollectionsByUser :many
|
||||
SELECT * FROM collections WHERE user_id = $1 ORDER BY created_at DESC;
|
||||
SELECT * FROM collections WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC;
|
||||
|
||||
-- Get collection with book count
|
||||
-- name: GetCollectionWithBookCount :one
|
||||
@@ -2176,6 +2312,11 @@ SET
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
|
||||
-- of the JSONB without touching metadata stale clients did not send).
|
||||
-- name: UpdateCollectionRulesJSON :one
|
||||
UPDATE collections SET auto_assign_rules = $2 WHERE id = $1 RETURNING *;
|
||||
|
||||
-- Delete collection
|
||||
-- name: DeleteCollection :exec
|
||||
DELETE FROM collections WHERE id = $1;
|
||||
@@ -2208,7 +2349,8 @@ ORDER BY ci.added_at DESC;
|
||||
SELECT c.*
|
||||
FROM collections c
|
||||
JOIN collection_items ci ON c.id = ci.collection_id
|
||||
WHERE ci.media_item_id = $1;
|
||||
WHERE ci.media_item_id = $1
|
||||
AND c.deleted_at IS NULL;
|
||||
|
||||
-- Check if book is in collection
|
||||
-- name: IsBookInCollection :one
|
||||
@@ -2435,9 +2577,13 @@ LIMIT $1 OFFSET $2;
|
||||
-- ============================================
|
||||
|
||||
-- Dashboard preferences queries
|
||||
-- The handler routes by scope: a Valid library_id targets the per-library
|
||||
-- table below; an absent one targets user_dashboard_preferences_global
|
||||
-- (the "All Libraries" view). Never pass NULL here — the loose NULL match
|
||||
-- was removed because it returned an arbitrary library's row.
|
||||
-- name: GetDashboardPreferences :one
|
||||
SELECT * FROM user_dashboard_preferences
|
||||
WHERE user_id = sqlc.narg('user_id') AND (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid);
|
||||
WHERE user_id = sqlc.narg('user_id') AND library_id = sqlc.narg('library_id')::uuid;
|
||||
|
||||
-- name: UpsertDashboardPreferences :one
|
||||
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
|
||||
@@ -2459,12 +2605,29 @@ SET hidden_collections = $2,
|
||||
WHERE user_id = $1 AND library_id = $5
|
||||
RETURNING *;
|
||||
|
||||
-- Global (all-libraries) scope: exactly one row per user.
|
||||
-- name: GetGlobalDashboardPreferences :one
|
||||
SELECT user_id, hidden_collections, collection_order, items_per_section, updated_at FROM user_dashboard_preferences_global
|
||||
WHERE user_id = $1;
|
||||
|
||||
-- name: UpsertGlobalDashboardPreferences :one
|
||||
INSERT INTO user_dashboard_preferences_global (user_id, hidden_collections, collection_order, items_per_section)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET
|
||||
hidden_collections = EXCLUDED.hidden_collections,
|
||||
collection_order = EXCLUDED.collection_order,
|
||||
items_per_section = EXCLUDED.items_per_section,
|
||||
updated_at = NOW()
|
||||
RETURNING user_id, hidden_collections, collection_order, items_per_section, updated_at;
|
||||
|
||||
-- Dashboard collections queries
|
||||
-- name: GetSystemCollectionsForDashboard :many
|
||||
SELECT * FROM collections
|
||||
WHERE user_id = $1
|
||||
AND is_system_collection = true
|
||||
AND show_on_dashboard = true
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY priority ASC;
|
||||
|
||||
-- name: GetUserCollectionsForDashboard :many
|
||||
@@ -2472,6 +2635,7 @@ SELECT c.* FROM collections c
|
||||
WHERE c.user_id = $1
|
||||
AND c.show_on_dashboard = true
|
||||
AND c.is_system_collection = false
|
||||
AND c.deleted_at IS NULL
|
||||
ORDER BY priority ASC;
|
||||
|
||||
-- name: DeleteUserSystemCollection :exec
|
||||
@@ -2480,6 +2644,38 @@ WHERE user_id = $1
|
||||
AND name = $2
|
||||
AND is_system_collection = true;
|
||||
|
||||
-- System collections are permanent: identity is (user_id, query_type).
|
||||
-- Restore/reset operate IN PLACE (the row id — and everything referencing
|
||||
-- it: shelf mappings, prefs order, links — never churns). Deleted sections
|
||||
-- are TOMBSTONED (deleted_at) and stay invisible until restored.
|
||||
|
||||
-- name: GetSystemCollectionByQueryType :one
|
||||
-- Unfiltered by design: restore/seed must resolve tombstoned rows too.
|
||||
SELECT * FROM collections
|
||||
WHERE user_id = $1
|
||||
AND query_type = $2
|
||||
AND is_system_collection = true;
|
||||
|
||||
-- name: TombstoneSystemCollection :exec
|
||||
UPDATE collections SET deleted_at = NOW() WHERE id = $1;
|
||||
|
||||
-- name: RestoreSystemCollectionRow :one
|
||||
-- Full reset, in place: canonical metadata, back on the dashboard,
|
||||
-- un-tombstoned. Item assignments are cleared by the caller ("full").
|
||||
UPDATE collections
|
||||
SET name = $2,
|
||||
description = $3,
|
||||
icon = $4,
|
||||
color = $5,
|
||||
priority = $6,
|
||||
show_on_dashboard = true,
|
||||
deleted_at = NULL
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: ClearCollectionItems :exec
|
||||
DELETE FROM collection_items WHERE collection_id = $1;
|
||||
|
||||
-- name: CreateSystemCollection :one
|
||||
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb)
|
||||
@@ -2559,6 +2755,31 @@ WHERE ci.collection_id = sqlc.narg('collection_id')
|
||||
ORDER BY ci.added_at DESC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- Smart ("dynamic") collections: rules are evaluated LIVE at render, so
|
||||
-- membership comes from the candidate book set instead of collection_items.
|
||||
-- The set is scoped to the requesting user's visible libraries (family
|
||||
-- lens enforced at the source) and live items only — the same filters the
|
||||
-- static collection paths apply.
|
||||
-- name: GetVisibleLiveMediaForRules :many
|
||||
SELECT mi.* FROM media_items mi
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY mi.title ASC;
|
||||
|
||||
-- Live item count for a STATIC collection (rule-free cards on the
|
||||
-- collections page); same live filter as GetCollectionItems, plus the
|
||||
-- future-hybrid excluded exclusion and an optional library scope.
|
||||
-- name: CountCollectionLiveItems :one
|
||||
SELECT COUNT(*)::int FROM collection_items ci
|
||||
JOIN media_items mi ON ci.media_item_id = mi.id
|
||||
WHERE ci.collection_id = $1
|
||||
AND NOT COALESCE(ci.excluded, false)
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0;
|
||||
|
||||
-- name: GetLibraryItems :many
|
||||
SELECT mi.* FROM media_items mi
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
@@ -2567,30 +2788,51 @@ WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('libra
|
||||
ORDER BY mi.created_at DESC;
|
||||
|
||||
-- name: GetDistinctSeries :many
|
||||
SELECT series, COUNT(*) as book_count,
|
||||
-- Series are grouped PER LIBRARY: the same series name in two libraries
|
||||
-- (novels vs manga adaptations, LN splits) stays two entries. Visible
|
||||
-- filter enforces per-user library visibility at the source.
|
||||
SELECT library_id, series, COUNT(*) as book_count,
|
||||
MAX(series_count) as total_in_series,
|
||||
MAX(created_at) as last_entry_at
|
||||
FROM media_items
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) AND series IS NOT NULL AND series != ''
|
||||
GROUP BY series
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
AND archived_at IS NULL
|
||||
AND missing_scan_count = 0
|
||||
AND series IS NOT NULL AND series != ''
|
||||
GROUP BY library_id, series
|
||||
ORDER BY MAX(created_at) DESC
|
||||
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
||||
|
||||
-- name: GetDistinctSeriesCount :one
|
||||
SELECT COUNT(DISTINCT series)::int
|
||||
SELECT COUNT(DISTINCT (library_id, series))::int
|
||||
FROM media_items
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) AND series IS NOT NULL AND series != '';
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
AND archived_at IS NULL
|
||||
AND missing_scan_count = 0
|
||||
AND series IS NOT NULL AND series != '';
|
||||
|
||||
-- name: GetSeriesCovers :many
|
||||
SELECT cover_image_path, library_id
|
||||
FROM media_items
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) AND series = sqlc.narg('series') AND cover_image_path IS NOT NULL AND cover_image_path != ''
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
AND archived_at IS NULL
|
||||
AND missing_scan_count = 0
|
||||
AND series = sqlc.narg('series') AND cover_image_path IS NOT NULL AND cover_image_path != ''
|
||||
ORDER BY series_number ASC NULLS LAST
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: GetSeriesBooks :many
|
||||
-- Scoped to ONE library: same-name series in different libraries are
|
||||
-- different stories (manga adaptations, LN splits). Callers must pass a
|
||||
-- library_id and verify the requester can see that library.
|
||||
SELECT * FROM media_items
|
||||
WHERE series = sqlc.narg('series')
|
||||
AND library_id = sqlc.narg('library_id')
|
||||
AND archived_at IS NULL
|
||||
AND missing_scan_count = 0
|
||||
ORDER BY series_number ASC NULLS LAST;
|
||||
|
||||
-- name: GetContinueSeriesItems :many
|
||||
@@ -2731,7 +2973,7 @@ RETURNING *;
|
||||
|
||||
-- name: DeleteMediaBookmark :exec
|
||||
DELETE FROM media_bookmarks
|
||||
WHERE id = $1;
|
||||
WHERE id = $1 AND user_id = $2;
|
||||
|
||||
-- name: UpdateMediaBookmark :one
|
||||
UPDATE media_bookmarks
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -74,22 +75,32 @@ func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error {
|
||||
startDate := c.QueryParam("start_date")
|
||||
endDate := c.QueryParam("end_date")
|
||||
|
||||
// All range boundaries and day buckets are computed in the REQUESTING
|
||||
// USER'S timezone: "today" and "which day did this session land on" are
|
||||
// per-user calendar concepts, not UTC ones.
|
||||
loc := utils.UserLocation(user.Timezone.String)
|
||||
now := time.Now().In(loc)
|
||||
|
||||
if startDate == "" {
|
||||
startDate = time.Now().AddDate(0, -1, 0).Format("01-02-2006")
|
||||
startDate = now.AddDate(0, -1, 0).Format("01-02-2006")
|
||||
}
|
||||
if endDate == "" {
|
||||
endDate = time.Now().Format("01-02-2006")
|
||||
endDate = now.Format("01-02-2006")
|
||||
}
|
||||
|
||||
startTime, err := time.Parse("01-02-2006", startDate)
|
||||
startTime, err := time.ParseInLocation("01-02-2006", startDate, loc)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid start_date format")
|
||||
}
|
||||
|
||||
endTime, err := time.Parse("01-02-2006", endDate)
|
||||
endTime, err := time.ParseInLocation("01-02-2006", endDate, loc)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid end_date format")
|
||||
}
|
||||
// The query bounds with <=, so the end bound must be the last instant
|
||||
// OF the end date — otherwise the end date's own sessions (including
|
||||
// "today") never appear.
|
||||
endTime = endTime.AddDate(0, 0, 1).Add(-time.Nanosecond)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -103,12 +114,12 @@ func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get reading history")
|
||||
}
|
||||
|
||||
stats := h.calculateReadingStats(history)
|
||||
stats := h.calculateReadingStats(history, loc)
|
||||
|
||||
return c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadingHistoryRow) ReadingStatsResponse {
|
||||
func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadingHistoryRow, loc *time.Location) ReadingStatsResponse {
|
||||
stats := ReadingStatsResponse{
|
||||
DailyReadingMinutes: make([]DailyReading, 0),
|
||||
}
|
||||
@@ -130,7 +141,7 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
|
||||
longestSession = int(minutes)
|
||||
}
|
||||
|
||||
dateKey := entry.CreatedAt.Time.Format("01-02-2006")
|
||||
dateKey := entry.CreatedAt.Time.In(loc).Format("01-02-2006")
|
||||
if dailyMap[dateKey] == nil {
|
||||
dailyMap[dateKey] = &DailyReading{
|
||||
Date: dateKey,
|
||||
@@ -141,7 +152,7 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
|
||||
|
||||
if entry.PagesRead.Valid {
|
||||
totalPages += int(entry.PagesRead.Int32)
|
||||
dateKey := entry.CreatedAt.Time.Format("01-02-2006")
|
||||
dateKey := entry.CreatedAt.Time.In(loc).Format("01-02-2006")
|
||||
if dailyMap[dateKey] != nil {
|
||||
dailyMap[dateKey].Pages += int(entry.PagesRead.Int32)
|
||||
}
|
||||
@@ -174,7 +185,7 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
|
||||
stats.CompletionRate = float64(booksCompleted) / float64(totalBooks)
|
||||
}
|
||||
|
||||
stats.MostActiveDay = h.calculateMostActiveDay(history)
|
||||
stats.MostActiveDay = h.calculateMostActiveDay(history, loc)
|
||||
|
||||
for _, data := range dailyMap {
|
||||
stats.DailyReadingMinutes = append(stats.DailyReadingMinutes, *data)
|
||||
@@ -183,13 +194,13 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
|
||||
return stats
|
||||
}
|
||||
|
||||
func (h *AnalyticsHandler) calculateMostActiveDay(history []database.GetUserReadingHistoryRow) string {
|
||||
func (h *AnalyticsHandler) calculateMostActiveDay(history []database.GetUserReadingHistoryRow, loc *time.Location) string {
|
||||
dayCount := make(map[string]int)
|
||||
|
||||
days := []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
|
||||
|
||||
for _, entry := range history {
|
||||
day := days[entry.CreatedAt.Time.Weekday()]
|
||||
day := days[entry.CreatedAt.Time.In(loc).Weekday()]
|
||||
dayCount[day]++
|
||||
}
|
||||
|
||||
@@ -222,7 +233,7 @@ func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error {
|
||||
lastSync := ""
|
||||
if u.LastSync != nil {
|
||||
if t, ok := u.LastSync.(time.Time); ok {
|
||||
lastSync = t.Format("01-02-2006 03:04:05 PM")
|
||||
lastSync = utils.FormatTimeInTimezone(t, user.Timezone.String)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,7 +285,7 @@ func (h *AnalyticsHandler) GetPopularBooks(c *echo.Context) error {
|
||||
lastRead := ""
|
||||
if book.LastRead != nil {
|
||||
if t, ok := book.LastRead.(time.Time); ok {
|
||||
lastRead = t.Format("01-02-2006 03:04:05 PM")
|
||||
lastRead = utils.FormatTimeInTimezone(t, user.Timezone.String)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+147
-1
@@ -10,6 +10,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -38,6 +39,23 @@ var SessionDurationSec = int(DefaultSessionDuration.Seconds())
|
||||
// SetSettings wires the tunable settings registry (optional).
|
||||
func (h *AuthHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
|
||||
|
||||
// defaultTimezone returns the system-wide default timezone for new users.
|
||||
// Falls back to UTC when no registry is wired, the setting is empty, or
|
||||
// the value is not a valid IANA location.
|
||||
func (h *AuthHandler) defaultTimezone() string {
|
||||
if h.settings == nil {
|
||||
return "UTC"
|
||||
}
|
||||
tz := h.settings.DefaultTimezone()
|
||||
if tz == "" {
|
||||
return "UTC"
|
||||
}
|
||||
if _, err := time.LoadLocation(tz); err != nil {
|
||||
return "UTC"
|
||||
}
|
||||
return tz
|
||||
}
|
||||
|
||||
// sessionDuration returns the active session duration from the registry.
|
||||
func (h *AuthHandler) sessionDuration() time.Duration {
|
||||
if h.settings != nil {
|
||||
@@ -262,6 +280,7 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
||||
LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""},
|
||||
Theme: pgtype.Text{String: "tokyo-night", Valid: true},
|
||||
Role: userRole,
|
||||
Timezone: pgtype.Text{String: h.defaultTimezone(), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
@@ -674,6 +693,15 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Browser path (htmx modal + profile page): refresh so every rendered
|
||||
// value (timezone select, table rows, header) reflects the save and the
|
||||
// modal closes. A bare JSON body swapped into #profile-result read as
|
||||
// "nothing happened" — the save DID land, invisibly.
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
c.Response().Header().Set("HX-Refresh", "true")
|
||||
return c.HTML(http.StatusOK, `<div class="text-green-500">Profile updated.</div>`)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "profile updated successfully"})
|
||||
}
|
||||
|
||||
@@ -686,6 +714,104 @@ func (h *AuthHandler) ListUsers(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, users)
|
||||
}
|
||||
|
||||
// GetPasswordRules handles GET /api/auth/password-rules (public — the
|
||||
// register page is pre-auth). Exposes the ACTIVE complexity configuration
|
||||
// so the UI requirement checkers mirror the real rules instead of
|
||||
// hardcoding defaults. The server remains the enforcer.
|
||||
func (h *AuthHandler) GetPasswordRules(c *echo.Context) error {
|
||||
minLength, upper, lower, number, special := 8, true, true, true, true
|
||||
if h.settings != nil {
|
||||
r := h.settings.PasswordRules()
|
||||
minLength, upper, lower, number, special =
|
||||
r.MinLength, r.Upper, r.Lower, r.Number, r.Special
|
||||
}
|
||||
if minLength < 1 {
|
||||
minLength = 8
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"min_length": minLength,
|
||||
"require_upper": upper,
|
||||
"require_lower": lower,
|
||||
"require_number": number,
|
||||
"require_special": special,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminCreateUser handles POST /api/auth/users (AdminMiddleware-gated).
|
||||
// The admin's counterpart to public registration: creating an account for
|
||||
// someone else must work even when self-service registration is disabled
|
||||
// (the family/self-hosted default). The requester's admin role comes from
|
||||
// the route group, and the new account NEVER auto-logins — no tokens are
|
||||
// issued here, unlike Register.
|
||||
func (h *AuthHandler) AdminCreateUser(c *echo.Context) error {
|
||||
var req RegisterRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
if req.Username == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "username cannot be empty or whitespace"})
|
||||
}
|
||||
|
||||
role := strings.ToLower(req.Role)
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
if role != "user" && role != "admin" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid role. must be 'user' or 'admin'"})
|
||||
}
|
||||
|
||||
if _, err := h.db.GetUserByEmail(c.Request().Context(), req.Email); err == nil {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "email already exists"})
|
||||
}
|
||||
if _, err := h.db.GetUserByUsername(c.Request().Context(), req.Username); err == nil {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "username already exists"})
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
|
||||
}
|
||||
|
||||
user, err := h.db.CreateUser(c.Request().Context(), database.CreateUserParams{
|
||||
Email: req.Email,
|
||||
Username: req.Username,
|
||||
PasswordHash: string(hashedPassword),
|
||||
FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""},
|
||||
LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""},
|
||||
Theme: pgtype.Text{String: "tokyo-night", Valid: true},
|
||||
Role: role,
|
||||
Timezone: pgtype.Text{String: h.defaultTimezone(), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
if err := h.CreateDefaultCollectionsForUser(c.Request().Context(), user.ID); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create default collections"})
|
||||
}
|
||||
|
||||
// The admin UI posts this via HTMX: refresh the page so the new user
|
||||
// appears in the table, and confirm inline.
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
c.Response().Header().Set("HX-Refresh", "true")
|
||||
return c.HTML(http.StatusCreated, `<div class="text-green-500">User "`+template.HTMLEscapeString(user.Username)+`" created.</div>`)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusCreated, map[string]interface{}{
|
||||
"id": uuid.UUID(user.ID.Bytes).String(),
|
||||
"email": user.Email,
|
||||
"username": user.Username,
|
||||
"role": user.Role,
|
||||
"timezone": user.Timezone,
|
||||
"created_at": user.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// normalizePath cleans and normalizes folder paths for consistent storage and comparison
|
||||
func normalizePath(path string) string {
|
||||
fmt.Printf("normalizePath input: '%s'\n", path)
|
||||
@@ -1091,7 +1217,27 @@ func (h *AuthHandler) CreateDefaultCollectionsForUser(ctx context.Context, userI
|
||||
}
|
||||
|
||||
for _, col := range defaultCollections {
|
||||
_, err := h.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
|
||||
// Idempotent by the STABLE identity (user + query_type): a
|
||||
// preserved or renamed row heals to canonical metadata instead of
|
||||
// duplicating (the naming-drift bug). Insert only when absent.
|
||||
existing, err := h.db.GetSystemCollectionByQueryType(ctx, database.GetSystemCollectionByQueryTypeParams{
|
||||
UserID: userID,
|
||||
QueryType: pgtype.Text{String: col.QueryType, Valid: true},
|
||||
})
|
||||
if err == nil {
|
||||
if _, uerr := h.db.RestoreSystemCollectionRow(ctx, database.RestoreSystemCollectionRowParams{
|
||||
ID: existing.ID,
|
||||
Name: col.Name,
|
||||
Description: pgtype.Text{String: col.Description, Valid: true},
|
||||
Icon: pgtype.Text{String: col.Icon, Valid: true},
|
||||
Color: pgtype.Text{String: col.Color, Valid: true},
|
||||
Priority: pgtype.Int4{Int32: col.Priority, Valid: true},
|
||||
}); uerr != nil {
|
||||
return uerr
|
||||
}
|
||||
continue
|
||||
}
|
||||
_, err = h.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
|
||||
UserID: userID,
|
||||
Name: col.Name,
|
||||
Description: pgtype.Text{String: col.Description, Valid: true},
|
||||
|
||||
@@ -40,6 +40,7 @@ type CreateCollectionRequest struct {
|
||||
Description string `form:"description" json:"description"`
|
||||
Color string `form:"color" json:"color"`
|
||||
Icon string `form:"icon" json:"icon"`
|
||||
CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart" (rule-based)
|
||||
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
|
||||
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
|
||||
}
|
||||
@@ -111,10 +112,14 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
||||
req.ViewSettings,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrCollectionNameTaken) {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "a collection with this name already exists"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
bookCount := int32(0)
|
||||
isSmart := req.CollectionType == "smart" || len(req.AutoAssignRules) > 0
|
||||
response := map[string]interface{}{
|
||||
"id": uuid.UUID(collection.ID.Bytes).String(),
|
||||
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
||||
@@ -122,6 +127,7 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
||||
"description": textToString(collection.Description),
|
||||
"color": textToString(collection.Color),
|
||||
"icon": textToString(collection.Icon),
|
||||
"collection_type": map[bool]string{true: "smart", false: "static"}[isSmart],
|
||||
"auto_assign_rules": collection.AutoAssignRules,
|
||||
"view_settings": collection.ViewSettings,
|
||||
"book_count": bookCount,
|
||||
@@ -130,8 +136,14 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
||||
|
||||
// Add redirect header for HTMX requests
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
// A smart collection starts with an empty ruleset; the builder is
|
||||
// where it becomes real. Static collections head back to the grid.
|
||||
if isSmart {
|
||||
c.Response().Header().Set("HX-Redirect", "/collections/"+uuid.UUID(collection.ID.Bytes).String()+"/rules")
|
||||
} else {
|
||||
c.Response().Header().Set("HX-Redirect", "/collections")
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusCreated, response)
|
||||
}
|
||||
@@ -155,30 +167,45 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
||||
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
BookCount int `json:"book_count"`
|
||||
IsSmart bool `json:"is_smart"`
|
||||
}
|
||||
|
||||
response := make([]CollectionResponse, 0, len(collections))
|
||||
for _, col := range collections {
|
||||
if !includeAuto && len(col.AutoAssignRules) > 0 {
|
||||
isSmart := h.collectionService.IsSmartCollection(col.AutoAssignRules)
|
||||
if !includeAuto && isSmart {
|
||||
// Legacy default for API clients (the Android app pre-dates
|
||||
// smart collections): rule-driven collections stay opt-in so
|
||||
// their membership could never be stale for old readers.
|
||||
continue
|
||||
}
|
||||
|
||||
bookCount := 0
|
||||
var libUUID pgtype.UUID
|
||||
if libraryID != "" {
|
||||
libUUID, libErr := uuid.Parse(libraryID)
|
||||
if libErr == nil {
|
||||
items, countErr := h.db.GetCollectionItemsForDashboard(c.Request().Context(), database.GetCollectionItemsForDashboardParams{
|
||||
if libParsed, libErr := uuid.Parse(libraryID); libErr == nil {
|
||||
libUUID = pgtype.UUID{Bytes: libParsed, Valid: true}
|
||||
}
|
||||
}
|
||||
if isSmart {
|
||||
rules := h.collectionService.ParseRules(col.AutoAssignRules)
|
||||
visibleIDs, visErr := h.userVisibleLibraryIDs(c)
|
||||
if visErr == nil {
|
||||
matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{
|
||||
LibraryID: libUUID,
|
||||
VisibleIDs: visibleIDs,
|
||||
})
|
||||
if evalErr == nil {
|
||||
bookCount = len(matches)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
count, countErr := h.db.CountCollectionLiveItems(c.Request().Context(), database.CountCollectionLiveItemsParams{
|
||||
CollectionID: pgtype.UUID{Bytes: col.ID.Bytes, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
Limit: pgtype.Int4{Int32: 10000, Valid: true},
|
||||
LibraryID: libUUID,
|
||||
})
|
||||
if countErr == nil {
|
||||
for _, item := range items {
|
||||
if !item.Excluded.Valid || !item.Excluded.Bool {
|
||||
bookCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
bookCount = int(count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +218,7 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
||||
AutoAssignRules: col.AutoAssignRules,
|
||||
CreatedAt: col.CreatedAt.Time.String(),
|
||||
BookCount: bookCount,
|
||||
IsSmart: isSmart,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -233,7 +261,31 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
||||
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
}
|
||||
|
||||
if collection.QueryType.Valid && collection.QueryType.String != "" {
|
||||
isSmart := h.collectionService.IsSmartCollection(collection.AutoAssignRules)
|
||||
if isSmart {
|
||||
rules := h.collectionService.ParseRules(collection.AutoAssignRules)
|
||||
visibleIDs, err := h.userVisibleLibraryIDs(c)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{
|
||||
LibraryID: libUUID,
|
||||
VisibleIDs: visibleIDs,
|
||||
})
|
||||
if evalErr != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": evalErr.Error()})
|
||||
}
|
||||
bookList = make([]BookInfo, 0, len(matches))
|
||||
for _, item := range matches {
|
||||
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||
bookList = append(bookList, BookInfo{
|
||||
MediaItemID: itemUUID.String(),
|
||||
Title: item.Title,
|
||||
Author: textToString(item.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||
})
|
||||
}
|
||||
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
|
||||
user := c.Get("user").(database.Users)
|
||||
userUUID := uuid.UUID(user.ID.Bytes)
|
||||
dashboardSvc := services.NewDashboardService(h.db)
|
||||
@@ -320,6 +372,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
||||
"view_settings": viewSettings,
|
||||
"books": bookList,
|
||||
"book_count": len(bookList),
|
||||
"is_smart": isSmart,
|
||||
"created_at": collection.CreatedAt.Time.String(),
|
||||
})
|
||||
}
|
||||
@@ -397,6 +450,10 @@ func (h *CollectionHandler) AddBooks(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||
}
|
||||
|
||||
if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil {
|
||||
return guardErr
|
||||
}
|
||||
|
||||
user := c.Get("user").(database.Users)
|
||||
userUUID := uuid.UUID(user.ID.Bytes)
|
||||
|
||||
@@ -444,6 +501,10 @@ func (h *CollectionHandler) RemoveBook(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||
}
|
||||
|
||||
if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil {
|
||||
return guardErr
|
||||
}
|
||||
|
||||
bookID, err := uuid.Parse(c.Param("bookId"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"})
|
||||
@@ -484,6 +545,10 @@ func (h *CollectionHandler) BulkRemoveBooks(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||
}
|
||||
|
||||
if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil {
|
||||
return guardErr
|
||||
}
|
||||
|
||||
user := c.Get("user").(database.Users)
|
||||
userUUID := uuid.UUID(user.ID.Bytes)
|
||||
|
||||
@@ -724,6 +789,170 @@ func (h *CollectionHandler) GetUserCollectionsList(c *echo.Context) ([]database.
|
||||
return h.collectionService.GetUserCollections(c.Request().Context(), userUUID)
|
||||
}
|
||||
|
||||
// userVisibleLibraryIDs loads the requester's visible-library set (the
|
||||
// family lens). Smart-collection evaluation funnels every book candidate
|
||||
// through it so hidden libraries contribute nothing.
|
||||
func (h *CollectionHandler) userVisibleLibraryIDs(c *echo.Context) ([]pgtype.UUID, error) {
|
||||
user, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
return nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
||||
}
|
||||
rows, err := h.libraryService.GetUserVisibleLibraries(c.Request().Context(), user.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check library access: %w", err)
|
||||
}
|
||||
ids := make([]pgtype.UUID, 0, len(rows))
|
||||
for _, lib := range rows {
|
||||
if lib.ID.Valid {
|
||||
ids = append(ids, lib.ID)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// UserVisibleLibraryIDs is the exported entry point for the router's
|
||||
// frontend page handlers, which resolve the visible-library set before
|
||||
// evaluating smart-collection rule matches.
|
||||
func (h *CollectionHandler) UserVisibleLibraryIDs(c *echo.Context) ([]pgtype.UUID, error) {
|
||||
return h.userVisibleLibraryIDs(c)
|
||||
}
|
||||
|
||||
// CollectionService returns the underlying service so the router's
|
||||
// frontend handlers can share the exact same evaluation the API uses.
|
||||
func (h *CollectionHandler) CollectionService() *services.CollectionService {
|
||||
return h.collectionService
|
||||
}
|
||||
|
||||
// rejectSmartMutation returns a 400 for smart collections: membership is
|
||||
// derived live from rules, so stored link mutations have nothing to act
|
||||
// on. v1 has no hybrid mode — add/remove with both rules and hand-picked
|
||||
// books is deliberately unsupported (collection_items.excluded awaits it).
|
||||
func (h *CollectionHandler) rejectSmartMutation(c *echo.Context, collectionID uuid.UUID) *echo.HTTPError {
|
||||
collection, err := h.GetCollectionData(c, collectionID)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "collection not found")
|
||||
}
|
||||
if h.collectionService.IsSmartCollection(collection.AutoAssignRules) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "smart collections are rule-based — membership is managed by the collection's rules, not by hand")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type AddRuleRequest struct {
|
||||
Field string `json:"field" validate:"required"`
|
||||
Operator string `json:"operator" validate:"required"`
|
||||
Value string `json:"value" validate:"required"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
// AddRule handles POST /api/collections/:id/rules — appends one rule to
|
||||
// the ruleset. The first rule converts a static collection to smart.
|
||||
func (h *CollectionHandler) AddRule(c *echo.Context) error {
|
||||
collectionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||
}
|
||||
|
||||
collection, err := h.GetCollectionData(c, collectionID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
|
||||
}
|
||||
if collection.IsSystemCollection.Bool {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "system collections cannot have rules"})
|
||||
}
|
||||
|
||||
var req AddRuleRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
updated, rule, err := h.collectionService.AddRule(c.Request().Context(), collectionID, services.Rule{
|
||||
Field: req.Field,
|
||||
Operator: req.Operator,
|
||||
Value: req.Value,
|
||||
Enabled: req.Enabled,
|
||||
Priority: req.Priority,
|
||||
})
|
||||
if err != nil {
|
||||
var ruleErr *services.RuleValidationError
|
||||
if errors.As(err, &ruleErr) {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusCreated, map[string]interface{}{
|
||||
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
|
||||
"rule": rule,
|
||||
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateRule handles PUT /api/collections/:id/rules/:ruleId — currently
|
||||
// the enabled toggle; the payload may only carry {"enabled": bool}.
|
||||
func (h *CollectionHandler) UpdateRule(c *echo.Context) error {
|
||||
collectionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||
}
|
||||
ruleID := c.Param("ruleId")
|
||||
if ruleID == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "rule id required"})
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled" validate:"required"`
|
||||
}
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
updated, err := h.collectionService.SetRuleEnabled(c.Request().Context(), collectionID, ruleID, *req.Enabled)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "rule not found") {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
|
||||
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveRule handles DELETE /api/collections/:id/rules/:ruleId.
|
||||
func (h *CollectionHandler) RemoveRule(c *echo.Context) error {
|
||||
collectionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||
}
|
||||
ruleID := c.Param("ruleId")
|
||||
if ruleID == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "rule id required"})
|
||||
}
|
||||
|
||||
updated, err := h.collectionService.RemoveRule(c.Request().Context(), collectionID, ruleID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "rule not found") {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
|
||||
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
|
||||
})
|
||||
}
|
||||
|
||||
type TestRulesRequest struct {
|
||||
Rules []map[string]interface{} `json:"rules" validate:"required"`
|
||||
}
|
||||
@@ -745,16 +974,32 @@ func (h *CollectionHandler) TestRules(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
mediaItems, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
|
||||
Limit: 1000,
|
||||
Offset: 0,
|
||||
// Candidates mirror what smart collections actually evaluate against:
|
||||
// the requester's visible libraries and live items only — the preview
|
||||
// must not promise matches the live render will never produce.
|
||||
visibleIDs, err := h.userVisibleLibraryIDs(c)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
var visibleArg []pgtype.UUID
|
||||
if len(visibleIDs) > 0 {
|
||||
visibleArg = visibleIDs
|
||||
}
|
||||
mediaItems, err := h.db.GetVisibleLiveMediaForRules(c.Request().Context(), database.GetVisibleLiveMediaForRulesParams{
|
||||
LibraryID: pgtype.UUID{},
|
||||
VisibleLibraryIds: visibleArg,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load media items"})
|
||||
}
|
||||
|
||||
listRows := make([]database.ListMediaItemsRow, len(mediaItems))
|
||||
for i, item := range mediaItems {
|
||||
listRows[i] = mediaItemsToListMediaItemsRow(item)
|
||||
}
|
||||
|
||||
var matches []BookMatch
|
||||
for _, item := range mediaItems {
|
||||
for _, item := range listRows {
|
||||
matchReason := h.checkRulesAgainstBook(item, req.Rules)
|
||||
if matchReason != "" {
|
||||
author := ""
|
||||
@@ -883,6 +1128,16 @@ func (h *CollectionHandler) HandleBulkAddBooks(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "operations required"})
|
||||
}
|
||||
|
||||
// Smart collections are rejected up front per operation so the
|
||||
// caller sees a clean, actionable error instead of silent no-ops.
|
||||
for _, op := range req.Operations {
|
||||
if colUUID, err := uuid.Parse(op.CollectionID); err == nil {
|
||||
if guardErr := h.rejectSmartMutation(c, colUUID); guardErr != nil {
|
||||
return guardErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0)
|
||||
successCount := 0
|
||||
failedCount := 0
|
||||
|
||||
@@ -406,6 +406,7 @@ func (h *ConflictHandler) applyHighlightResolution(ctx context.Context, mediaIte
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
DeviceSyncData: existing.DeviceSyncData,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
if v, ok := data["selection_text"].(string); ok {
|
||||
@@ -451,6 +452,7 @@ func (h *ConflictHandler) applyBookmarkResolution(ctx context.Context, mediaItem
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
DeviceSyncData: existing.DeviceSyncData,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
if v, ok := data["title"].(string); ok {
|
||||
@@ -486,6 +488,7 @@ func (h *ConflictHandler) applyNoteResolution(ctx context.Context, mediaItemID p
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
DeviceSyncData: existing.DeviceSyncData,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
if v, ok := data["content"].(string); ok {
|
||||
|
||||
@@ -75,7 +75,9 @@ func (h *DashboardHandler) UpdatePreferences(c *echo.Context) error {
|
||||
userUUID := uuid.UUID(user.ID.Bytes)
|
||||
|
||||
var req struct {
|
||||
LibraryID string `json:"library_id"`
|
||||
// nil (JSON null/absent) = the global All Libraries scope;
|
||||
// empty string is a LOST value, not a scope — rejected loudly.
|
||||
LibraryID *string `json:"library_id"`
|
||||
HiddenCollections []string `json:"hidden_collections"`
|
||||
CollectionOrder []string `json:"collection_order"`
|
||||
ItemsPerSection int `json:"items_per_section"`
|
||||
@@ -85,10 +87,17 @@ func (h *DashboardHandler) UpdatePreferences(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
||||
}
|
||||
|
||||
libUUID, err := uuid.Parse(req.LibraryID)
|
||||
var libUUID pgtype.UUID
|
||||
if req.LibraryID != nil {
|
||||
if *req.LibraryID == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
}
|
||||
parsed, err := uuid.Parse(*req.LibraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
}
|
||||
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
}
|
||||
|
||||
// Sanitize preferences (remove duplicates)
|
||||
cleanHidden, cleanOrder := h.dashboardService.SanitizeDashboardPreferences(req.HiddenCollections, req.CollectionOrder)
|
||||
@@ -97,7 +106,7 @@ func (h *DashboardHandler) UpdatePreferences(c *echo.Context) error {
|
||||
|
||||
prefs, err := h.dashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
LibraryID: libUUID,
|
||||
HiddenCollections: req.HiddenCollections,
|
||||
CollectionOrder: req.CollectionOrder,
|
||||
ItemsPerSection: pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true},
|
||||
|
||||
@@ -458,6 +458,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
DeviceType: "kobo",
|
||||
DeviceName: device.DeviceName,
|
||||
Broadcast: true,
|
||||
ClearLocator: true,
|
||||
})
|
||||
} else {
|
||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||
@@ -806,6 +807,7 @@ func (h *KoboHandler) AnalyticsGettests(c *echo.Context) error {
|
||||
DeviceType: "kobo",
|
||||
DeviceName: device.DeviceName,
|
||||
Broadcast: true,
|
||||
ClearLocator: true,
|
||||
})
|
||||
} else {
|
||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||
@@ -868,6 +870,7 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
||||
DeviceType: "kobo",
|
||||
DeviceName: device.DeviceName,
|
||||
Broadcast: false,
|
||||
ClearLocator: true,
|
||||
})
|
||||
} else {
|
||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||
|
||||
@@ -93,7 +93,22 @@ func (h *KOReaderHandler) convertHighlightPositions(ec annotationEpub, pos0, pos
|
||||
if pos0 == "" || !ec.convertible() {
|
||||
return "", ""
|
||||
}
|
||||
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, percentage, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
|
||||
// Primary: anchor both endpoints by the selection text itself. One
|
||||
// document match yields a same-spine ordered pair — the invariant the
|
||||
// web reader's range-CFI builder needs. Independent per-endpoint
|
||||
// conversion mis-anchors any selection past a block's first text node
|
||||
// (crengine text-node indices don't survive to the raw XHTML).
|
||||
startLoc := wsync.ConvertHighlightRangeToCanonical(ec.epubPath, ec.mediaItem.FormatGroup, pos0, pos1, contextText, percentage)
|
||||
if startLoc.Precision == "exact" || startLoc.Precision == "structural" {
|
||||
startCFI := webUsableCFI(startLoc)
|
||||
endCFI := ""
|
||||
if startCFI != "" && startLoc.EndCFI != "" {
|
||||
endCFI = webUsableCFI(wsync.CanonicalLocator{CFI: startLoc.EndCFI, Precision: startLoc.Precision})
|
||||
}
|
||||
return startCFI, endCFI
|
||||
}
|
||||
// Fallback: legacy independent per-endpoint conversion.
|
||||
startLoc = wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, percentage, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
|
||||
startCFI := webUsableCFI(startLoc)
|
||||
endCFI := ""
|
||||
// A text-search start matched the selection text itself: its extent
|
||||
@@ -862,6 +877,11 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
|
||||
|
||||
if h.progressSvc != nil {
|
||||
epubcfi := book.Epubcfi
|
||||
// No position pointer from the device, or a conversion that
|
||||
// declined below: the write is percentage-only by intent. The
|
||||
// service then clears any inherited stale anchor instead of
|
||||
// re-pairing it with the new percentage.
|
||||
clearLocator := epubcfi == nil
|
||||
if epubcfi != nil && wsync.IsCREXPointer(*epubcfi) {
|
||||
// Same facade every annotation uses: cached converter,
|
||||
// structural-first resolution, guarded fallbacks. The facade
|
||||
@@ -897,6 +917,7 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
|
||||
// and the device keeps its own native position.
|
||||
log.Printf("Bookhoard: CRE→CFI conversion low-confidence (%s) for %s; storing percentage only", loc.Precision, *epubcfi)
|
||||
epubcfi = nil
|
||||
clearLocator = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -916,6 +937,7 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
|
||||
DeviceType: "koreader",
|
||||
DeviceName: deviceModel,
|
||||
Broadcast: true,
|
||||
ClearLocator: clearLocator,
|
||||
}
|
||||
|
||||
_, err := h.progressSvc.SaveProgress(ctx, saveReq)
|
||||
|
||||
+237
-15
@@ -439,6 +439,20 @@ func (h *MediaHandler) HandleBulkDelete(c *echo.Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Storage-down guard: deleting cascades the item's annotations, so
|
||||
// refuse while the library storage is unreachable — the file would
|
||||
// survive as an orphan and re-import bare on the next scan.
|
||||
storage := h.resolveMediaStorage(c.Request().Context(), uuid.UUID(media.LibraryID.Bytes).String(), media.FilePath)
|
||||
if !storage.Reachable {
|
||||
results = append(results, map[string]interface{}{
|
||||
"media_item_id": mediaIDStr,
|
||||
"status": "error",
|
||||
"error": "storage unreachable — archive the item instead, or retry when the storage is mounted",
|
||||
})
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
err = h.db.DeleteMediaItem(c.Request().Context(), mediaUUID)
|
||||
if err != nil {
|
||||
results = append(results, map[string]interface{}{
|
||||
@@ -450,11 +464,11 @@ func (h *MediaHandler) HandleBulkDelete(c *echo.Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
if media.FilePath != "" {
|
||||
if err := os.Remove(media.FilePath); err != nil {
|
||||
if storage.AbsolutePath != "" {
|
||||
if err := os.Remove(storage.AbsolutePath); err != nil {
|
||||
// Log file removal failure but don't fail the request
|
||||
// DB record is already deleted, which is the primary concern
|
||||
log.Printf("Warning: failed to remove file %s: %v", media.FilePath, err)
|
||||
log.Printf("Warning: failed to remove file %s: %v", storage.AbsolutePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,6 +640,77 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// visibleLibrarySet loads the requester's visible libraries — membership
|
||||
// map for param checks, id slice for SQL ANY() filters.
|
||||
func (mh *MediaHandler) visibleLibrarySet(c *echo.Context) (map[string]bool, []pgtype.UUID, error) {
|
||||
user, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
||||
}
|
||||
libraries, err := mh.libraryService.GetUserVisibleLibraries(c.Request().Context(), user.ID)
|
||||
if err != nil {
|
||||
return nil, nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to check library access")
|
||||
}
|
||||
set := make(map[string]bool, len(libraries))
|
||||
ids := make([]pgtype.UUID, 0, len(libraries))
|
||||
for _, lib := range libraries {
|
||||
if !lib.ID.Valid {
|
||||
continue
|
||||
}
|
||||
set[uuid.UUID(lib.ID.Bytes).String()] = true
|
||||
ids = append(ids, lib.ID)
|
||||
}
|
||||
return set, ids, nil
|
||||
}
|
||||
|
||||
// mediaStorageState reports whether the library storage holding a media
|
||||
// item's file is currently reachable, and resolves the file's absolute
|
||||
// path when it can be located.
|
||||
//
|
||||
// Why this exists: deleting a media item cascades its annotations, so the
|
||||
// delete must never proceed while the storage is unreachable — otherwise
|
||||
// a NAS outage turns an admin cleanup into permanent annotation loss
|
||||
// (the DB row dies, the orphaned file re-imports bare on the next scan).
|
||||
type mediaStorageState struct {
|
||||
// Absolute path of the file when located on reachable storage;
|
||||
// empty when the file is missing or reachability is undeterminable.
|
||||
AbsolutePath string
|
||||
// Reachable is false ONLY when the library has folders and every one
|
||||
// of them fails to stat — positive evidence the mount is down. When
|
||||
// no folders are configured reachability is undeterminable and this
|
||||
// is true (delete proceeds; behavior unchanged from before this check).
|
||||
Reachable bool
|
||||
}
|
||||
|
||||
func (mh *MediaHandler) resolveMediaStorage(ctx context.Context, libraryID string, relativePath string) mediaStorageState {
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
return mediaStorageState{Reachable: true}
|
||||
}
|
||||
folders, err := mh.libraryService.GetLibraryFolders(ctx, pgtype.UUID{Bytes: libUUID, Valid: true})
|
||||
if err != nil || len(folders) == 0 {
|
||||
return mediaStorageState{Reachable: true}
|
||||
}
|
||||
|
||||
anyFolderUp := false
|
||||
for _, f := range folders {
|
||||
if _, err := os.Stat(f.FolderPath); err != nil {
|
||||
continue // folder root unreachable
|
||||
}
|
||||
anyFolderUp = true
|
||||
candidate := filepath.Join(f.FolderPath, filepath.FromSlash(relativePath))
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return mediaStorageState{AbsolutePath: candidate, Reachable: true}
|
||||
}
|
||||
}
|
||||
if !anyFolderUp {
|
||||
return mediaStorageState{Reachable: false}
|
||||
}
|
||||
// Storage is up but the file is genuinely gone: a stale row is safe
|
||||
// to delete.
|
||||
return mediaStorageState{Reachable: true}
|
||||
}
|
||||
|
||||
// ListMediaItems handles GET /api/media-items
|
||||
func (mh *MediaHandler) ListMediaItems(c *echo.Context) error {
|
||||
libraryID := c.QueryParam("library_id")
|
||||
@@ -678,6 +763,14 @@ func (mh *MediaHandler) ListMediaItems(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"})
|
||||
}
|
||||
|
||||
visibleSet, _, err := mh.visibleLibrarySet(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !visibleSet[libraryID] {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
||||
}
|
||||
|
||||
items, err := mh.db.ListMediaItemsSorted(c.Request().Context(), database.ListMediaItemsSortedParams{
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
Sort: pgtype.Text{String: sort, Valid: true},
|
||||
@@ -691,9 +784,14 @@ func (mh *MediaHandler) ListMediaItems(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"data": items})
|
||||
}
|
||||
|
||||
_, visibleIDs, err := mh.visibleLibrarySet(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := mh.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
|
||||
Limit: int32(limit),
|
||||
Offset: int32(offset),
|
||||
VisibleLibraryIds: visibleIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -764,6 +862,15 @@ func (mh *MediaHandler) GetMediaItem(c *echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Visibility gate: hidden libraries don't leak row detail by id.
|
||||
visibleSet, _, err := mh.visibleLibrarySet(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"id": uuid.UUID(item.ID.Bytes).String(),
|
||||
"library_id": uuid.UUID(item.LibraryID.Bytes).String(),
|
||||
@@ -1001,6 +1108,31 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||
}
|
||||
|
||||
// Reading-stats attribution: registered clients may send their device
|
||||
// token in X-Device-Token (the user JWT stays the primary auth). A
|
||||
// token that maps to a device owned by THIS user attaches the push —
|
||||
// and everything the reading history records about it — to that
|
||||
// device's registered name/type ("Pixel 9" · mobile, "Kindle… " ·
|
||||
// koreader). Absent/invalid/foreign tokens soft-fail to the plain
|
||||
// web attribution; a stats decoration must never fail a sync.
|
||||
deviceID := pgtype.UUID{}
|
||||
deviceSource := "web"
|
||||
deviceLabel := ""
|
||||
deviceType := ""
|
||||
if token := c.Request().Header.Get("X-Device-Token"); token != "" {
|
||||
device, devErr := mh.db.GetDeviceByAuthToken(c.Request().Context(), token)
|
||||
if devErr == nil && device.UserID.Valid && uuid.UUID(device.UserID.Bytes) == userUUID {
|
||||
deviceID = device.ID
|
||||
deviceLabel = device.DeviceName
|
||||
deviceType = device.DeviceType
|
||||
deviceSource = device.DeviceName
|
||||
if strings.TrimSpace(deviceSource) == "" {
|
||||
deviceSource = device.DeviceType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var req struct {
|
||||
CurrentPage *int32 `json:"current_page"`
|
||||
TotalPages *int32 `json:"total_pages"`
|
||||
@@ -1023,8 +1155,10 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
||||
saveReq := wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Source: "web",
|
||||
DeviceID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Source: deviceSource,
|
||||
DeviceID: deviceID,
|
||||
DeviceType: deviceType,
|
||||
DeviceName: deviceLabel,
|
||||
Percentage: req.Percentage,
|
||||
Epubcfi: req.Epubcfi,
|
||||
ContextText: req.ContextText,
|
||||
@@ -1037,8 +1171,6 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
||||
ScrollX: req.ScrollX,
|
||||
ScrollY: req.ScrollY,
|
||||
ReadingMode: req.ReadingMode,
|
||||
DeviceType: "web",
|
||||
DeviceName: "Web",
|
||||
Broadcast: true,
|
||||
}
|
||||
if req.CurrentPage != nil {
|
||||
@@ -1194,7 +1326,10 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
mediaItemID, err := mh.db.CreateMediaItem(c.Request().Context(), database.CreateMediaItemParams{
|
||||
// Race-safe create-or-detect: on a duplicate (library_id, file_path)
|
||||
// the insert writes nothing and returns no row — answer with the
|
||||
// EXISTING item, a duplicate flag, and 200 OK (not a 201 lie).
|
||||
created, err := mh.db.CreateMediaItemIfNew(c.Request().Context(), database.CreateMediaItemIfNewParams{
|
||||
LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true},
|
||||
Title: req.Title,
|
||||
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
||||
@@ -1215,11 +1350,25 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
|
||||
ContributorsSearch: contributorsSearch,
|
||||
AddedByAdminID: user.ID,
|
||||
})
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
existing, gerr := mh.db.GetMediaItemByLibraryAndPath(c.Request().Context(), database.GetMediaItemByLibraryAndPathParams{
|
||||
LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true},
|
||||
FilePath: req.FilePath,
|
||||
})
|
||||
if gerr != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": gerr.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"duplicate": true,
|
||||
"message": "A media item already exists for this file_path in this library",
|
||||
"item": existing,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaItemID.ID.Bytes, Valid: true})
|
||||
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: created.ID.Bytes, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
@@ -1537,11 +1686,35 @@ func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||
}
|
||||
|
||||
media, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Storage-down guard (see HandleBulkDelete): refuse while the library
|
||||
// storage is unreachable so the cascade can't destroy annotations for
|
||||
// a file that will re-import on the next scan.
|
||||
storage := mh.resolveMediaStorage(c.Request().Context(), uuid.UUID(media.LibraryID.Bytes).String(), media.FilePath)
|
||||
if !storage.Reachable {
|
||||
return c.JSON(http.StatusConflict, map[string]string{
|
||||
"error": "storage unreachable — archive the item instead, or retry when the storage is mounted",
|
||||
})
|
||||
}
|
||||
|
||||
err = mh.db.DeleteMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
if storage.AbsolutePath != "" {
|
||||
if err := os.Remove(storage.AbsolutePath); err != nil {
|
||||
log.Printf("Warning: failed to remove file %s: %v", storage.AbsolutePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -1649,6 +1822,12 @@ func (mh *MediaHandler) UpdateMediaNote(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
|
||||
}
|
||||
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
|
||||
var req UpdateMediaNoteRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
@@ -1661,6 +1840,7 @@ func (mh *MediaHandler) UpdateMediaNote(c *echo.Context) error {
|
||||
ID: pgtype.UUID{Bytes: noteUUID, Valid: true},
|
||||
Content: req.Content,
|
||||
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -1677,10 +1857,19 @@ func (mh *MediaHandler) DeleteMediaNote(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
|
||||
}
|
||||
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
|
||||
if mh.annotationSvc != nil {
|
||||
err = mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||
err = mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true}, pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||
} else {
|
||||
err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||
err = mh.db.DeleteMediaNote(c.Request().Context(), database.DeleteMediaNoteParams{
|
||||
ID: pgtype.UUID{Bytes: noteUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -1882,6 +2071,12 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, result.Highlight)
|
||||
}
|
||||
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
|
||||
highlight, err := mh.db.UpdateMediaHighlight(c.Request().Context(), database.UpdateMediaHighlightParams{
|
||||
ID: pgtype.UUID{Bytes: highlightUUID, Valid: true},
|
||||
SelectionText: req.SelectionText,
|
||||
@@ -1889,6 +2084,7 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error {
|
||||
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
|
||||
Color: pgtype.Text{String: color, Valid: true},
|
||||
NoteID: noteUUID,
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -1907,14 +2103,27 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error {
|
||||
|
||||
pgHighlightID := pgtype.UUID{Bytes: highlightUUID, Valid: true}
|
||||
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
||||
|
||||
if mh.annotationSvc != nil {
|
||||
if err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgHighlightID, "web"); err != nil {
|
||||
if err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgUserID, pgHighlightID, "web"); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "highlight not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgHighlightID)
|
||||
err = mh.db.DeleteMediaHighlight(c.Request().Context(), database.DeleteMediaHighlightParams{
|
||||
ID: pgHighlightID,
|
||||
UserID: pgUserID,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
@@ -2059,14 +2268,27 @@ func (mh *MediaHandler) DeleteMediaBookmark(c *echo.Context) error {
|
||||
|
||||
pgBookmarkID := pgtype.UUID{Bytes: bookmarkUUID, Valid: true}
|
||||
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
||||
|
||||
if mh.annotationSvc != nil {
|
||||
if err := mh.annotationSvc.TombstoneBookmarkByID(c.Request().Context(), pgBookmarkID, "web"); err != nil {
|
||||
if err := mh.annotationSvc.TombstoneBookmarkByID(c.Request().Context(), pgUserID, pgBookmarkID, "web"); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "bookmark not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
if err := mh.db.DeleteMediaBookmark(c.Request().Context(), pgBookmarkID); err != nil {
|
||||
if err := mh.db.DeleteMediaBookmark(c.Request().Context(), database.DeleteMediaBookmarkParams{
|
||||
ID: pgBookmarkID,
|
||||
UserID: pgUserID,
|
||||
}); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ type MediaDetail struct {
|
||||
Collections []database.Collections `json:"collections"`
|
||||
ReadingProgress *database.ReadingProgress `json:"reading_progress,omitempty"`
|
||||
|
||||
// The viewer's own reading-history card for this book (nil when the
|
||||
// user has no sessions here); rendered as the "Your Reading Stats"
|
||||
// section of the book page.
|
||||
BookStats *BookStatsData `json:"book_stats,omitempty"`
|
||||
|
||||
// Conflict data (if exists)
|
||||
ActiveConflict *ConflictDetailResponse `json:"active_conflict,omitempty"`
|
||||
|
||||
@@ -27,3 +32,14 @@ type MediaDetail struct {
|
||||
// page's "recently deleted" list with restore/permanent-delete actions.
|
||||
DeletedAnnotations []DeletedAnnotationResponse `json:"deleted_annotations"`
|
||||
}
|
||||
|
||||
// BookStatsData is the per-book reading-stats card payload.
|
||||
type BookStatsData struct {
|
||||
TotalMinutes int
|
||||
TotalPages int
|
||||
SessionCount int
|
||||
MaxProgress float64 // 0..1
|
||||
Finished bool
|
||||
LastSession string
|
||||
FirstSession string
|
||||
}
|
||||
@@ -402,7 +402,7 @@ func (h *OPDSHandler) addAcquisitionEntries(ctx context.Context, feed *opds.Feed
|
||||
author = item.Author.String
|
||||
}
|
||||
|
||||
updated := item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z")
|
||||
updated := item.UpdatedAt.Time.UTC().Format("2006-01-02T15:04:05Z")
|
||||
|
||||
entry := opds.NewEntry(
|
||||
fmt.Sprintf("urn:uuid:%s", bookUUID),
|
||||
@@ -706,7 +706,7 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||
author = item.Author.String
|
||||
}
|
||||
|
||||
updated := item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z")
|
||||
updated := item.UpdatedAt.Time.UTC().Format("2006-01-02T15:04:05Z")
|
||||
|
||||
entry := opds.NewEntry(
|
||||
fmt.Sprintf("urn:uuid:%s", bookUUID),
|
||||
|
||||
@@ -97,121 +97,6 @@ func (h *Handler) GetUniversalProgress(c *echo.Context) error {
|
||||
}
|
||||
|
||||
// UpdateUniversalProgress updates progress with automatic conversion
|
||||
func (h *Handler) UpdateUniversalProgress(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
|
||||
mediaItemID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid media item ID")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Source string `json:"source"`
|
||||
Location struct {
|
||||
Percentage *float64 `json:"percentage"`
|
||||
Page *int `json:"page"`
|
||||
TotalPages *int `json:"total_pages"`
|
||||
Epubcfi *string `json:"epubcfi"`
|
||||
Character *int64 `json:"character"`
|
||||
Chapter *int `json:"chapter"`
|
||||
ViewportY *float64 `json:"viewport_y"`
|
||||
} `json:"location"`
|
||||
DeviceMetadata struct {
|
||||
DeviceType string `json:"device_type"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
} `json:"device_metadata"`
|
||||
}
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
mediaItem, err := h.db.GetMediaItem(context.Background(), pgtype.UUID{Bytes: mediaItemID, Valid: true})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "media item not found")
|
||||
}
|
||||
|
||||
percentage := 0.0
|
||||
if req.Location.Percentage != nil {
|
||||
percentage = *req.Location.Percentage
|
||||
} else if req.Location.Page != nil && req.Location.TotalPages != nil {
|
||||
percentage = wsync.PageToPercentage(*req.Location.Page, *req.Location.TotalPages)
|
||||
}
|
||||
|
||||
currentPage := 0
|
||||
totalPages := 0
|
||||
if req.Location.Page != nil {
|
||||
currentPage = *req.Location.Page
|
||||
}
|
||||
if req.Location.TotalPages != nil {
|
||||
totalPages = *req.Location.TotalPages
|
||||
}
|
||||
if mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 {
|
||||
totalPages = int(mediaItem.PageCount.Int32)
|
||||
}
|
||||
|
||||
var epubcfi pgtype.Text
|
||||
if req.Location.Epubcfi != nil {
|
||||
epubcfi = pgtype.Text{String: *req.Location.Epubcfi, Valid: true}
|
||||
}
|
||||
|
||||
var chapter pgtype.Int4
|
||||
if req.Location.Chapter != nil {
|
||||
chapter = pgtype.Int4{Int32: int32(*req.Location.Chapter), Valid: true}
|
||||
}
|
||||
|
||||
var viewportY pgtype.Float8
|
||||
if req.Location.ViewportY != nil {
|
||||
viewportY = pgtype.Float8{Float64: *req.Location.ViewportY, Valid: true}
|
||||
}
|
||||
|
||||
var lastSyncDevice, lastSyncSource pgtype.Text
|
||||
if req.DeviceMetadata.DeviceType != "" {
|
||||
lastSyncDevice = pgtype.Text{String: req.DeviceMetadata.DeviceType, Valid: true}
|
||||
lastSyncSource = pgtype.Text{String: req.Source, Valid: true}
|
||||
}
|
||||
|
||||
updated, err := h.db.UpdateUniversalProgress(context.Background(), database.UpdateUniversalProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
||||
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
|
||||
Epubcfi: epubcfi,
|
||||
Chapter: chapter,
|
||||
ChapterProgress: pgtype.Float8{Float64: percentage, Valid: true},
|
||||
ViewportY: viewportY,
|
||||
LastSyncDevice: lastSyncDevice,
|
||||
LastSyncSource: lastSyncSource,
|
||||
CurrentPage: pgtype.Int4{Int32: int32(currentPage), Valid: true},
|
||||
TotalPages: pgtype.Int4{Int32: int32(totalPages), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to update progress")
|
||||
}
|
||||
|
||||
// Broadcast progress update to all connected WebSocket clients
|
||||
deviceName := "Web"
|
||||
deviceType := "web"
|
||||
if req.DeviceMetadata.DeviceType != "" {
|
||||
deviceType = req.DeviceMetadata.DeviceType
|
||||
deviceName = req.DeviceMetadata.DeviceType
|
||||
}
|
||||
|
||||
h.connManager.BroadcastProgressUpdate(mediaItemID, percentage, wsync.SourceDevice{
|
||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||
Name: deviceName,
|
||||
Type: deviceType,
|
||||
})
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"sync_status": "success",
|
||||
"progress_updated": true,
|
||||
"percentage": percentage,
|
||||
"current_page": currentPage,
|
||||
"total_pages": totalPages,
|
||||
"last_read_at": updated.LastReadAt.Time,
|
||||
})
|
||||
}
|
||||
|
||||
// GetProgressHistory retrieves reading session history
|
||||
func (h *Handler) GetProgressHistory(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
@@ -306,7 +191,7 @@ func (h *Handler) GetAllProgress(c *echo.Context) error {
|
||||
|
||||
lastUpdated := ""
|
||||
if progress.LastReadAt.Valid {
|
||||
lastUpdated = progress.LastReadAt.Time.Format("01-02-2006 03:04 PM")
|
||||
lastUpdated = utils.FormatTimeInTimezone(progress.LastReadAt.Time, user.Timezone.String)
|
||||
}
|
||||
|
||||
progressList = append(progressList, ProgressWithMedia{
|
||||
@@ -376,7 +261,7 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
|
||||
|
||||
lastUpdated := ""
|
||||
if progress.LastReadAt.Valid {
|
||||
lastUpdated = progress.LastReadAt.Time.Format("01-02-2006 03:04 PM")
|
||||
lastUpdated = utils.FormatTimeInTimezone(progress.LastReadAt.Time, user.Timezone.String)
|
||||
}
|
||||
|
||||
progressList = append(progressList, ProgressWithMedia{
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -47,6 +48,10 @@ type QueueItemResponse struct {
|
||||
Status string `json:"status"`
|
||||
ErrorMessage *string `json:"error_message,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
// CreatedAtDisplay is the server-rendered wall-clock string in the
|
||||
// requesting admin's timezone (templates render this; created_at stays
|
||||
// machine-readable RFC3339 for API consumers).
|
||||
CreatedAtDisplay string `json:"created_at_display,omitempty"`
|
||||
ProcessedAt *string `json:"processed_at,omitempty"`
|
||||
}
|
||||
|
||||
@@ -136,6 +141,8 @@ func (h *QueueHandler) ListDeviceQueueItems(c *echo.Context) error {
|
||||
Status: item.Status.String,
|
||||
ErrorMessage: textPtrToString(item.ErrorMessage),
|
||||
CreatedAt: item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
|
||||
CreatedAtDisplay: utils.FormatTimeInTimezone(
|
||||
item.CreatedAt.Time, user.Timezone.String),
|
||||
ProcessedAt: timestamptzPtrToString(item.ProcessedAt),
|
||||
})
|
||||
}
|
||||
@@ -189,6 +196,8 @@ func (h *QueueHandler) GetQueueData(c *echo.Context) ([]QueueItemResponse, error
|
||||
Status: item.Status.String,
|
||||
ErrorMessage: textPtrToString(item.ErrorMessage),
|
||||
CreatedAt: item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
|
||||
CreatedAtDisplay: utils.FormatTimeInTimezone(
|
||||
item.CreatedAt.Time, user.Timezone.String),
|
||||
ProcessedAt: timestamptzPtrToString(item.ProcessedAt),
|
||||
})
|
||||
}
|
||||
|
||||
+43
-51
@@ -213,6 +213,27 @@ func (h *ReaderHandler) GetPage(c *echo.Context) error {
|
||||
}
|
||||
|
||||
// GetChapters returns chapter metadata
|
||||
// ensureItemVisible enforces the library-visibility gate for reader
|
||||
// content and layout endpoints (same rule GetPage applies): a hidden
|
||||
// library leaks nothing by item id.
|
||||
func (h *ReaderHandler) ensureItemVisible(c *echo.Context, mediaItem *database.MediaItems) error {
|
||||
user := c.Get("user")
|
||||
if user == nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "User not authenticated"})
|
||||
}
|
||||
userData := user.(database.Users)
|
||||
libraries, err := h.libraryService.GetUserVisibleLibraries(c.Request().Context(), userData.ID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to check library access"})
|
||||
}
|
||||
for _, lib := range libraries {
|
||||
if lib.ID == mediaItem.LibraryID {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "Access denied to this library"})
|
||||
}
|
||||
|
||||
func (h *ReaderHandler) GetChapters(c *echo.Context) error {
|
||||
mediaItemID := c.Param("mediaItemId")
|
||||
parsedUUID, err := uuid.Parse(mediaItemID)
|
||||
@@ -220,6 +241,14 @@ func (h *ReaderHandler) GetChapters(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
|
||||
}
|
||||
|
||||
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
|
||||
}
|
||||
if err := h.ensureItemVisible(c, &mediaItem); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use reader service to detect chapters
|
||||
chapters, err := h.readerService.DetectChapters(c.Request().Context(), parsedUUID)
|
||||
if err != nil {
|
||||
@@ -246,6 +275,14 @@ func (h *ReaderHandler) GetPanels(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"})
|
||||
}
|
||||
|
||||
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
|
||||
}
|
||||
if err := h.ensureItemVisible(c, &mediaItem); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get detection method from query
|
||||
method := c.QueryParam("method")
|
||||
if method == "" {
|
||||
@@ -398,6 +435,9 @@ func (h *ReaderHandler) GetPDFOutline(c *echo.Context) error {
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
|
||||
}
|
||||
if err := h.ensureItemVisible(c, &mediaItem); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Only PDFs have outlines
|
||||
if mediaItem.FormatGroup != "pdf" {
|
||||
@@ -438,6 +478,9 @@ func (h *ReaderHandler) GetPDFThumbnail(c *echo.Context) error {
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
|
||||
}
|
||||
if err := h.ensureItemVisible(c, &mediaItem); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get thumbnail size from query
|
||||
width := c.QueryParam("width")
|
||||
@@ -590,57 +633,6 @@ func (h *ReaderHandler) generatePDFThumbnail(fullPath string, page int, width, h
|
||||
return []byte{}, nil
|
||||
}
|
||||
|
||||
func (h *ReaderHandler) ParseEbook(c *echo.Context) error {
|
||||
mediaItemID := c.Param("mediaItemId")
|
||||
parsedUUID, err := uuid.Parse(mediaItemID)
|
||||
if err != nil {
|
||||
return c.JSON(400, map[string]string{"error": "Invalid media item ID"})
|
||||
}
|
||||
|
||||
// Fetch media item
|
||||
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(404, map[string]string{"error": "Media item not found"})
|
||||
}
|
||||
return c.JSON(500, map[string]string{"error": "Failed to fetch media item"})
|
||||
}
|
||||
|
||||
// Check if format requires server-side parsing
|
||||
requiresServerParsing := false
|
||||
formatName := ""
|
||||
switch mediaItem.MimeType.String {
|
||||
case "application/x-mobipocket-ebook":
|
||||
requiresServerParsing = true
|
||||
formatName = "MOBI"
|
||||
case "application/vnd.amazon.mobi8-ebook":
|
||||
requiresServerParsing = true
|
||||
formatName = "AZW3/KF8"
|
||||
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||
requiresServerParsing = true
|
||||
formatName = "DOCX"
|
||||
case "application/rtf":
|
||||
requiresServerParsing = true
|
||||
formatName = "RTF"
|
||||
}
|
||||
|
||||
if requiresServerParsing {
|
||||
// Phase 1: Return 501 for server-side formats
|
||||
return c.JSON(501, map[string]interface{}{
|
||||
"error": fmt.Sprintf("Server-side %s parsing is not yet implemented", formatName),
|
||||
"message": fmt.Sprintf("%s format support is planned for Phase 2.5", formatName),
|
||||
"suggestion": "Please convert your ebook to EPUB format for now",
|
||||
"format": formatName,
|
||||
"phase": "2.5",
|
||||
})
|
||||
}
|
||||
|
||||
// Client-side format - should not call this endpoint
|
||||
return c.JSON(400, map[string]string{
|
||||
"error": "This format should be parsed client-side, not on the server",
|
||||
})
|
||||
}
|
||||
|
||||
// ShouldTreatAsComic reports whether a fixed-layout item that lives in a
|
||||
// comics or manga library should get comic reader treatment: bookmarks
|
||||
// only — no text-selection highlights, no annotations, no in-book search.
|
||||
|
||||
@@ -50,13 +50,25 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, limit, offset)
|
||||
// Visibility scoping: an explicit library_id must be visible to the
|
||||
// requester (403 otherwise); the unscoped aggregate covers only the
|
||||
// requester's visible libraries.
|
||||
visibleSet, visibleIDs, err := h.visibleLibrarySet(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if libraryID != "" && !visibleSet[libraryID] {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
||||
}
|
||||
|
||||
seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, visibleIDs, limit, offset)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series"})
|
||||
}
|
||||
|
||||
type SeriesResponse struct {
|
||||
Name string `json:"name"`
|
||||
LibraryID string `json:"library_id"`
|
||||
BookCount int64 `json:"book_count"`
|
||||
TotalInSeries int `json:"total_in_series"`
|
||||
CoverPaths []string `json:"cover_paths"`
|
||||
@@ -67,6 +79,7 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
|
||||
for _, s := range seriesList {
|
||||
response = append(response, SeriesResponse{
|
||||
Name: s.Name,
|
||||
LibraryID: s.LibraryID,
|
||||
BookCount: s.BookCount,
|
||||
TotalInSeries: s.TotalInSeries,
|
||||
CoverPaths: s.CoverPaths,
|
||||
@@ -82,13 +95,58 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// visibleLibrarySet loads the requester's visible libraries. Every series
|
||||
// path funnels through this: hidden libraries contribute nothing, and an
|
||||
// explicit library_id outside the set is a 403, not a silent pass.
|
||||
func (h *SeriesHandler) visibleLibrarySet(c *echo.Context) (map[string]bool, []pgtype.UUID, error) {
|
||||
user, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
||||
}
|
||||
libraries, err := h.seriesService.VisibleLibraries(c.Request().Context(), user.ID)
|
||||
if err != nil {
|
||||
return nil, nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to check library access")
|
||||
}
|
||||
set := make(map[string]bool, len(libraries))
|
||||
ids := make([]pgtype.UUID, 0, len(libraries))
|
||||
for _, lib := range libraries {
|
||||
if !lib.ID.Valid {
|
||||
continue
|
||||
}
|
||||
libUUID, err := uuid.FromBytes(lib.ID.Bytes[0:16])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
set[libUUID.String()] = true
|
||||
ids = append(ids, lib.ID)
|
||||
}
|
||||
return set, ids, nil
|
||||
}
|
||||
|
||||
func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
|
||||
seriesName := c.QueryParam("name")
|
||||
if seriesName == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "name required"})
|
||||
}
|
||||
|
||||
books, err := h.seriesService.GetSeriesBooks(c.Request().Context(), seriesName)
|
||||
libraryID := c.QueryParam("library_id")
|
||||
if libraryID == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
|
||||
}
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
}
|
||||
|
||||
visibleSet, _, err := h.visibleLibrarySet(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !visibleSet[libraryID] {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
||||
}
|
||||
|
||||
books, err := h.seriesService.GetSeriesBooks(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true}, seriesName)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series books"})
|
||||
}
|
||||
@@ -106,12 +164,13 @@ func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"name": seriesName,
|
||||
"library_id": libraryID,
|
||||
"books": bookCards,
|
||||
"total": len(bookCards),
|
||||
})
|
||||
}
|
||||
|
||||
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, limit, offset int) ([]services.SeriesInfo, int, error) {
|
||||
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int) ([]services.SeriesInfo, int, error) {
|
||||
svc := services.NewSeriesService(db)
|
||||
return svc.GetSeriesPage(ctx, libraryID, limit, offset)
|
||||
return svc.GetSeriesPage(ctx, libraryID, visibleLibraryIDs, limit, offset)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/services"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
// StatsHandler serves the reading-statistics API (/api/stats/*) backed
|
||||
// by the StatisticsService. The server owns every conversion: clients
|
||||
// receive user-timezone-rendered strings plus raw minute/page counts.
|
||||
type StatsHandler struct {
|
||||
statsService *services.StatisticsService
|
||||
}
|
||||
|
||||
func NewStatsHandler(statsService *services.StatisticsService) *StatsHandler {
|
||||
return &StatsHandler{statsService: statsService}
|
||||
}
|
||||
|
||||
func mustUser(c *echo.Context) (database.Users, bool) {
|
||||
user, ok := c.Get("user").(database.Users)
|
||||
return user, ok
|
||||
}
|
||||
|
||||
// Summary handles GET /api/stats/summary?range=7|30|90|year — the /stats
|
||||
// page's complete payload: totals, daily arrays, streaks, most-active
|
||||
// day, top books, per-library breakdown.
|
||||
func (h *StatsHandler) Summary(c *echo.Context) error {
|
||||
user, ok := mustUser(c)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
||||
}
|
||||
summary, err := h.statsService.UserSummary(c.Request().Context(),
|
||||
pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
||||
user.Timezone.String,
|
||||
c.QueryParam("range"),
|
||||
)
|
||||
if err != nil {
|
||||
if err.Error()[:7] == "invalid" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return c.JSON(http.StatusOK, summary)
|
||||
}
|
||||
|
||||
// ThisWeek handles GET /api/stats/this-week — the dashboard widget's
|
||||
// data: rolling 7-day totals vs the previous seven days, plus streak.
|
||||
// An optional ?library_id scopes the week to one library (the
|
||||
// dashboard's library switcher drives it).
|
||||
func (h *StatsHandler) ThisWeek(c *echo.Context) error {
|
||||
user, ok := mustUser(c)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
||||
}
|
||||
var libraryID pgtype.UUID
|
||||
if raw := c.QueryParam("library_id"); raw != "" {
|
||||
parsed, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid library id")
|
||||
}
|
||||
libraryID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
}
|
||||
week, err := h.statsService.ThisWeek(c.Request().Context(),
|
||||
pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
||||
user.Timezone.String,
|
||||
libraryID,
|
||||
)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return c.JSON(http.StatusOK, week)
|
||||
}
|
||||
|
||||
// BookStats handles GET /api/stats/books/:id — the per-book card on the
|
||||
// book detail page (lifetime totals + recent sessions).
|
||||
func (h *StatsHandler) BookStats(c *echo.Context) error {
|
||||
user, ok := mustUser(c)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
||||
}
|
||||
mediaItemID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid media item id")
|
||||
}
|
||||
stats, err := h.statsService.BookStats(c.Request().Context(),
|
||||
pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
||||
mediaItemID,
|
||||
user.Timezone.String,
|
||||
)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
@@ -8,9 +8,18 @@ func registerAnalyticsRoutes(cfg *Config) {
|
||||
|
||||
protected := e.Group("/api", jwtMiddleware)
|
||||
|
||||
// Analytics routes
|
||||
// Analytics routes (legacy shape; kept for existing API consumers —
|
||||
// the web UI moved to /stats)
|
||||
analytics := protected.Group("/analytics")
|
||||
analytics.GET("/reading-stats", cfg.AnalyticsHandler.GetReadingStats)
|
||||
analytics.GET("/device-usage", cfg.AnalyticsHandler.GetDeviceUsage)
|
||||
analytics.GET("/popular-books", cfg.AnalyticsHandler.GetPopularBooks)
|
||||
|
||||
// Reading-statistics API (/stats page + dashboard widget + per-book
|
||||
// card). Same session seed, service-backed aggregates, user-timezone
|
||||
// bucketing. The legacy /analytics page redirects to /stats.
|
||||
stats := protected.Group("/stats")
|
||||
stats.GET("/summary", cfg.StatsHandler.Summary)
|
||||
stats.GET("/this-week", cfg.StatsHandler.ThisWeek)
|
||||
stats.GET("/books/:id", cfg.StatsHandler.BookStats)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ func registerAuthRoutes(cfg *Config, rateLimitMiddleware echo.MiddlewareFunc) {
|
||||
// Auth routes (no auth required, but rate limited)
|
||||
e.POST("/api/auth/register", rateLimitMiddleware(cfg.AuthHandler.Register))
|
||||
e.POST("/api/auth/login", rateLimitMiddleware(cfg.AuthHandler.Login))
|
||||
e.GET("/api/auth/password-rules", cfg.AuthHandler.GetPasswordRules)
|
||||
|
||||
// JWT middleware for protected routes
|
||||
jwtMiddleware := createJWTMiddleware(cfg)
|
||||
@@ -40,6 +41,7 @@ func registerAuthRoutes(cfg *Config, rateLimitMiddleware echo.MiddlewareFunc) {
|
||||
// Admin-only routes (same handlers with URL param)
|
||||
admin := protected.Group("/auth", handlers.AdminMiddleware)
|
||||
admin.GET("/users", cfg.AuthHandler.ListUsers)
|
||||
admin.POST("/users", cfg.AuthHandler.AdminCreateUser)
|
||||
admin.PUT("/users/:id/max-devices", cfg.AuthHandler.UpdateUserMaxDevices)
|
||||
admin.PUT("/profile/:id", cfg.AuthHandler.UpdateProfile)
|
||||
admin.PUT("/password/:id", cfg.AuthHandler.UpdatePassword)
|
||||
|
||||
@@ -22,6 +22,12 @@ func registerCollectionsRoutes(cfg *Config) {
|
||||
collections.POST("/test-rules", cfg.CollectionHandler.TestRules)
|
||||
collections.POST("/preview", cfg.CollectionHandler.PreviewCollection)
|
||||
|
||||
// Smart collection rule CRUD (the rules builder persists through
|
||||
// these; type follows rules presence — first rule = smart).
|
||||
collections.POST("/:id/rules", cfg.CollectionHandler.AddRule)
|
||||
collections.PUT("/:id/rules/:ruleId", cfg.CollectionHandler.UpdateRule)
|
||||
collections.DELETE("/:id/rules/:ruleId", cfg.CollectionHandler.RemoveRule)
|
||||
|
||||
// Device shelf mapping routes
|
||||
deviceCollections := protected.Group("/devices/:id/collections")
|
||||
deviceCollections.GET("", cfg.CollectionHandler.GetDeviceMappings)
|
||||
|
||||
+263
-9
@@ -19,8 +19,10 @@ import (
|
||||
"bookhoard/internal/utils"
|
||||
"bookhoard/internal/version"
|
||||
"bookhoard/templates"
|
||||
"errors"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
|
||||
@@ -120,7 +122,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
// ============================================================================
|
||||
|
||||
// Protected frontend routes (no /api prefix)
|
||||
frontendProtected := e.Group("", jwtMiddleware, ensureUserExistsMiddleware(cfg))
|
||||
frontendProtected := e.Group("", jwtMiddleware)
|
||||
|
||||
// Series browse page
|
||||
frontendProtected.GET("/series", func(c *echo.Context) error {
|
||||
@@ -151,7 +153,16 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
totalPages := 1
|
||||
|
||||
if errorMsg == "" {
|
||||
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, perSeriesPage, offset)
|
||||
// Visible-library scoping: cards are grouped per library, and a
|
||||
// specific library selection only renders if it's in the set.
|
||||
visibleSet, visibleIDs, visErr := visibleLibraryIDsFor(c, cfg)
|
||||
if visErr != nil {
|
||||
log.Printf("visibleLibraryIDsFor failed: %v", visErr)
|
||||
errorMsg = "Error loading series"
|
||||
} else if libRes.LibUUID.Valid && !visibleSet[libRes.LibraryID] {
|
||||
errorMsg = "Library not accessible"
|
||||
} else {
|
||||
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, visibleIDs, perSeriesPage, offset)
|
||||
if err != nil {
|
||||
log.Printf("GetSeriesCardsData failed: %v", err)
|
||||
errorMsg = "Error loading series"
|
||||
@@ -168,6 +179,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
seriesCards = append(seriesCards, templates.SeriesCardData{
|
||||
Name: s.Name,
|
||||
LibraryID: s.LibraryID,
|
||||
BookCount: s.BookCount,
|
||||
TotalInSeries: s.TotalInSeries,
|
||||
CoverPaths: covers,
|
||||
@@ -175,6 +187,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if seriesCards == nil {
|
||||
seriesCards = []templates.SeriesCardData{}
|
||||
@@ -202,10 +215,28 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
return renderErrorPage(c, "Series name required", "bad_request")
|
||||
}
|
||||
|
||||
// Series are per-library now: the detail page must say WHICH library's
|
||||
// series it is showing, and the requester must be able to see it.
|
||||
libraryIDParam := c.QueryParam("library_id")
|
||||
if libraryIDParam == "" {
|
||||
return renderErrorPage(c, "Library required", "bad_request")
|
||||
}
|
||||
libUUIDParam, err := uuid.Parse(libraryIDParam)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Invalid library", "bad_request")
|
||||
}
|
||||
visibleSet, _, visErr := visibleLibraryIDsFor(c, cfg)
|
||||
if visErr != nil {
|
||||
return renderErrorPage(c, "Error checking access", "access_error")
|
||||
}
|
||||
if !visibleSet[libraryIDParam] {
|
||||
return renderErrorPage(c, "Access denied", "access_denied")
|
||||
}
|
||||
|
||||
var bookInfoList []handlers.BookInfo
|
||||
|
||||
svc := services.NewSeriesService(cfg.Queries)
|
||||
books, err := svc.GetSeriesBooks(c.Request().Context(), seriesName)
|
||||
books, err := svc.GetSeriesBooks(c.Request().Context(), pgtype.UUID{Bytes: libUUIDParam, Valid: true}, seriesName)
|
||||
if err != nil {
|
||||
log.Printf("GetSeriesBooks failed: %v", err)
|
||||
errorMsg = "Error loading series books"
|
||||
@@ -442,8 +473,36 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID))
|
||||
allSectionsData := handlers.BuildSections(allSections, libraryID)
|
||||
|
||||
// "This week" widget: the user's rolling 7-day reading pulse for
|
||||
// the CURRENTLY SELECTED library (the same resolution the sections
|
||||
// use), from the same session seed the /stats page serves.
|
||||
// Suppressed when the service fails (dashboard always renders).
|
||||
var week *templates.WeekStatsData
|
||||
if cfg.StatisticsService != nil {
|
||||
weekScope := pgtype.UUID{}
|
||||
if !libRes.IsAll {
|
||||
weekScope = libRes.LibUUID
|
||||
}
|
||||
weekSvc, weekErr := cfg.StatisticsService.ThisWeek(c.Request().Context(),
|
||||
pgtype.UUID{Bytes: userUUID, Valid: true}, user.Timezone, weekScope)
|
||||
if weekErr == nil {
|
||||
week = &templates.WeekStatsData{
|
||||
WeekMinutes: weekSvc.WeekMinutes,
|
||||
WeekPages: weekSvc.WeekPages,
|
||||
WeekSessions: weekSvc.WeekSessions,
|
||||
WeekBooks: weekSvc.WeekBooks,
|
||||
PrevMinutes: weekSvc.PrevMinutes,
|
||||
ActiveToday: weekSvc.ActiveToday,
|
||||
CurrentStreak: weekSvc.CurrentStreak,
|
||||
LibraryID: libRes.LibraryID,
|
||||
}
|
||||
} else {
|
||||
log.Printf("ThisWeek widget failed: %v", weekErr)
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, errorMsg).Render(c.Request().Context(), &buf)
|
||||
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, errorMsg).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -467,14 +526,56 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
errorMsg = "Error loading collections"
|
||||
}
|
||||
|
||||
// Live per-card counts: smart collections are rule-matched at
|
||||
// render against the requesting user's visible libraries; static
|
||||
// ones count their stored links. Library filter applies when a
|
||||
// specific library is selected.
|
||||
libRes := resolveLibrary(c, cfg, user.ID)
|
||||
var libScope pgtype.UUID
|
||||
if !libRes.IsAll {
|
||||
if parsed, parseErr := uuid.Parse(libRes.LibraryID); parseErr == nil {
|
||||
libScope = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
}
|
||||
}
|
||||
visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
|
||||
if visErr != nil {
|
||||
log.Printf("UserVisibleLibraryIDs failed: %v", visErr)
|
||||
visibleIDs = nil
|
||||
}
|
||||
|
||||
colData := make([]templates.CollectionData, len(collections))
|
||||
colService := cfg.CollectionHandler.CollectionService()
|
||||
for i, col := range collections {
|
||||
isSmart := colService.IsSmartCollection(col.AutoAssignRules)
|
||||
bookCount := 0
|
||||
if isSmart {
|
||||
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
|
||||
colService.ParseRules(col.AutoAssignRules), services.SmartEvalOptions{
|
||||
LibraryID: libScope,
|
||||
VisibleIDs: visibleIDs,
|
||||
})
|
||||
if evalErr == nil {
|
||||
bookCount = len(matches)
|
||||
}
|
||||
} else {
|
||||
count, countErr := cfg.Queries.CountCollectionLiveItems(c.Request().Context(), database.CountCollectionLiveItemsParams{
|
||||
CollectionID: pgtype.UUID{Bytes: col.ID.Bytes, Valid: true},
|
||||
LibraryID: libScope,
|
||||
})
|
||||
if countErr == nil {
|
||||
bookCount = int(count)
|
||||
}
|
||||
}
|
||||
|
||||
colData[i] = templates.CollectionData{
|
||||
ID: uuid.UUID(col.ID.Bytes).String(),
|
||||
Name: col.Name,
|
||||
Description: getText(col.Description),
|
||||
Color: getText(col.Color),
|
||||
Icon: getText(col.Icon),
|
||||
IsSystem: col.IsSystemCollection.Bool,
|
||||
IsSmart: isSmart,
|
||||
BookCount: bookCount,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,6 +644,47 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
|
||||
// Smart collection rules builder page (the smart editor). User
|
||||
// collections only: system sections keep their hardwired queries.
|
||||
frontendProtected.GET("/collections/:id/rules", func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
collectionID := c.Param("id")
|
||||
collUUID, err := uuid.Parse(collectionID)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Invalid collection ID", "invalid_id")
|
||||
}
|
||||
collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return renderErrorPage(c, "Collection not found", "not_found")
|
||||
}
|
||||
return renderErrorPage(c, "Error loading collection", "collection_load_error")
|
||||
}
|
||||
if collection.IsSystemCollection.Bool {
|
||||
return renderErrorPage(c, "System collections cannot have rules", "not_allowed")
|
||||
}
|
||||
|
||||
colData := templates.CollectionData{
|
||||
ID: collectionID,
|
||||
Name: collection.Name,
|
||||
Description: getText(collection.Description),
|
||||
Color: getText(collection.Color),
|
||||
Icon: getText(collection.Icon),
|
||||
IsSystem: collection.IsSystemCollection.Bool,
|
||||
IsSmart: cfg.CollectionHandler.CollectionService().IsSmartCollection(collection.AutoAssignRules),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.CollectionRules(user, colData).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
|
||||
// Collection detail page (works for both system and user collections)
|
||||
frontendProtected.GET("/collections/:id", func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
@@ -570,7 +712,44 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
libRes := resolveLibrary(c, cfg, user.ID)
|
||||
libraryID := libRes.LibraryID
|
||||
|
||||
if collection.QueryType.Valid && collection.QueryType.String != "" {
|
||||
var libScope pgtype.UUID
|
||||
if !libRes.IsAll {
|
||||
if parsed, parseErr := uuid.Parse(libraryID); parseErr == nil {
|
||||
libScope = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
}
|
||||
}
|
||||
colService := cfg.CollectionHandler.CollectionService()
|
||||
isSmart := colService.IsSmartCollection(collection.AutoAssignRules)
|
||||
|
||||
visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
|
||||
if visErr != nil {
|
||||
log.Printf("UserVisibleLibraryIDs failed: %v", visErr)
|
||||
visibleIDs = nil
|
||||
}
|
||||
|
||||
if isSmart {
|
||||
// Smart (dynamic) collection: membership is evaluated live —
|
||||
// visible libraries, live items only, any enabled rule matches.
|
||||
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
|
||||
colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{
|
||||
LibraryID: libScope,
|
||||
VisibleIDs: visibleIDs,
|
||||
})
|
||||
if evalErr != nil {
|
||||
return renderErrorPage(c, "Error loading books", "books_load_error")
|
||||
}
|
||||
bookCards := make([]handlers.BookInfo, 0, len(matches))
|
||||
for _, item := range matches {
|
||||
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||
bookCards = append(bookCards, handlers.BookInfo{
|
||||
MediaItemID: itemUUID.String(),
|
||||
Title: item.Title,
|
||||
Author: getText(item.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||
})
|
||||
}
|
||||
books = bookCards
|
||||
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
|
||||
dashboardSvc := services.NewDashboardService(cfg.Queries)
|
||||
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libRes.LibUUID, 1000, []string{}, []string{})
|
||||
if secErr != nil {
|
||||
@@ -655,6 +834,8 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
Color: collection.Color.String,
|
||||
Icon: collection.Icon.String,
|
||||
IsSystem: collection.IsSystemCollection.Bool,
|
||||
IsSmart: isSmart,
|
||||
BookCount: len(books),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
@@ -665,6 +846,46 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
|
||||
// Week-widget fragment: the collapsible "This Week" strip re-fetches
|
||||
// itself here (htmx) whenever the library switcher dispatches
|
||||
// week-refresh, carrying the current #library-select value. Same
|
||||
// resolution rules as the dashboard page (family-lens safe).
|
||||
frontendProtected.GET("/dashboard/week-widget", func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
||||
}
|
||||
if cfg.StatisticsService == nil {
|
||||
return echo.NewHTTPError(http.StatusServiceUnavailable, "stats unavailable")
|
||||
}
|
||||
libRes := resolveLibrary(c, cfg, user.ID)
|
||||
weekScope := pgtype.UUID{}
|
||||
if !libRes.IsAll {
|
||||
weekScope = libRes.LibUUID
|
||||
}
|
||||
userUUID, _ := uuid.Parse(user.ID)
|
||||
weekSvc, weekErr := cfg.StatisticsService.ThisWeek(c.Request().Context(),
|
||||
uuidToPGType(userUUID), user.Timezone, weekScope)
|
||||
if weekErr != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, weekErr.Error())
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err = templates.DashboardWeekWidget(templates.WeekStatsData{
|
||||
WeekMinutes: weekSvc.WeekMinutes,
|
||||
WeekPages: weekSvc.WeekPages,
|
||||
WeekSessions: weekSvc.WeekSessions,
|
||||
WeekBooks: weekSvc.WeekBooks,
|
||||
PrevMinutes: weekSvc.PrevMinutes,
|
||||
ActiveToday: weekSvc.ActiveToday,
|
||||
CurrentStreak: weekSvc.CurrentStreak,
|
||||
LibraryID: libRes.LibraryID,
|
||||
}).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
|
||||
// Custom Section Builder page
|
||||
frontendProtected.GET("/custom-section", func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
@@ -795,14 +1016,22 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
|
||||
// Analytics page
|
||||
// Analytics page → /stats (the rebuilt reading-statistics page;
|
||||
// nobody bookmarks orphan pages twice).
|
||||
frontendProtected.GET("/analytics", func(c *echo.Context) error {
|
||||
return c.Redirect(http.StatusMovedPermanently, "/stats")
|
||||
})
|
||||
|
||||
// Reading statistics page — per-user by default, with library
|
||||
// breakdown; the per-book tier lives on the book detail page and the
|
||||
// dashboard carries the "this week" widget.
|
||||
frontendProtected.GET("/stats", func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err = templates.Analytics(user).Render(c.Request().Context(), &buf)
|
||||
err = templates.Stats(user).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1042,7 +1271,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
LibraryName: p.LibraryName,
|
||||
SHA256: p.FileSha256,
|
||||
SHAShort: p.FileSha256[:16] + "…",
|
||||
CreatedAt: p.CreatedAt.Time.Format("Jan 2, 2006"),
|
||||
CreatedAt: p.CreatedAt.Time.In(utils.UserLocation(user.Timezone)).Format("Jan 2, 2006"),
|
||||
Items: []templates.HashConflictItemData{},
|
||||
}
|
||||
|
||||
@@ -1225,6 +1454,9 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
FirstName: getText(targetUser.FirstName),
|
||||
LastName: getText(targetUser.LastName),
|
||||
CreatedAt: targetUser.CreatedAt.Time,
|
||||
// Without this the timezone select rendered UTC selected for
|
||||
// EVERY user — saves landed, the modal just never showed them.
|
||||
Timezone: getText(targetUser.Timezone),
|
||||
}
|
||||
|
||||
// Render modal
|
||||
@@ -1421,6 +1653,24 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
UserID: pgUserID,
|
||||
})
|
||||
|
||||
// Per-book reading stats (the viewer's own history card) — nil
|
||||
// keeps the card off the page for untouched books / other users.
|
||||
var bookStats *handlers.BookStatsData
|
||||
if cfg.StatisticsService != nil {
|
||||
if svcStats, serr := cfg.StatisticsService.BookStats(c.Request().Context(),
|
||||
pgUserID, mediaUUID, user.Timezone); serr == nil && svcStats.SessionCount > 0 {
|
||||
bookStats = &handlers.BookStatsData{
|
||||
TotalMinutes: svcStats.TotalMinutes,
|
||||
TotalPages: svcStats.TotalPages,
|
||||
SessionCount: svcStats.SessionCount,
|
||||
MaxProgress: svcStats.MaxProgress,
|
||||
Finished: svcStats.Finished,
|
||||
FirstSession: svcStats.FirstSession,
|
||||
LastSession: svcStats.LastSession,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assemble response (no field duplication!)
|
||||
detail := handlers.MediaDetail{
|
||||
MediaItems: mediaItem, // Embedded - ALL fields available
|
||||
@@ -1428,6 +1678,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
Rating: rating,
|
||||
Collections: collections,
|
||||
ReadingProgress: progress,
|
||||
BookStats: bookStats,
|
||||
ActiveConflict: activeConflict,
|
||||
NotesCount: len(notes),
|
||||
HighlightsCount: len(highlights),
|
||||
@@ -1436,7 +1687,10 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
|
||||
// Render template
|
||||
var buf bytes.Buffer
|
||||
err = templates.BookDetail(user, detail, "").Render(c.Request().Context(), &buf)
|
||||
// The deleted-annotations modal states the retention in plain days
|
||||
// (the tunable's user-facing value), never as a settings name.
|
||||
tombstoneDays := cfg.Settings.TombstoneTTL() / (24 * time.Hour)
|
||||
err = templates.BookDetail(user, detail, "", int(tombstoneDays)).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package router
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
@@ -58,6 +60,9 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
|
||||
Theme: userTheme,
|
||||
Token: token,
|
||||
Timezone: userTimezone,
|
||||
FirstName: getText(userDB.FirstName),
|
||||
LastName: getText(userDB.LastName),
|
||||
CreatedAt: userDB.CreatedAt.Time,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -102,6 +107,34 @@ type LibraryResolution struct {
|
||||
FirstID string
|
||||
}
|
||||
|
||||
// folderStatEntry caches one folder-reachability result.
|
||||
type folderStatEntry struct {
|
||||
exists bool
|
||||
at time.Time
|
||||
}
|
||||
|
||||
const folderStatTTL = 30 * time.Second
|
||||
|
||||
var folderStatCache sync.Map
|
||||
|
||||
// folderReachableCached stats a library folder with a short TTL cache:
|
||||
// resolveLibrary runs on every SSR render, and a stat per library folder
|
||||
// over a network mount adds real latency to each page. The cache trades
|
||||
// ~30s of stale online/offline state for snappy renders.
|
||||
func folderReachableCached(path string) bool {
|
||||
if v, ok := folderStatCache.Load(path); ok {
|
||||
if e, ok := v.(folderStatEntry); ok && time.Since(e.at) < folderStatTTL {
|
||||
return e.exists
|
||||
}
|
||||
}
|
||||
exists := false
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
exists = true
|
||||
}
|
||||
folderStatCache.Store(path, folderStatEntry{exists: exists, at: time.Now()})
|
||||
return exists
|
||||
}
|
||||
|
||||
func getText(t pgtype.Text) string {
|
||||
if t.Valid {
|
||||
return t.String
|
||||
@@ -118,6 +151,15 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
|
||||
log.Printf("GetUserVisibleLibraries failed: %v", err)
|
||||
libraries = []database.GetUserVisibleLibrariesRow{}
|
||||
}
|
||||
visibleSet := make(map[string]bool, len(libraries))
|
||||
for _, lib := range libraries {
|
||||
if !lib.ID.Valid {
|
||||
continue
|
||||
}
|
||||
if id, err := uuid.FromBytes(lib.ID.Bytes[0:16]); err == nil {
|
||||
visibleSet[id.String()] = true
|
||||
}
|
||||
}
|
||||
|
||||
counts, countErr := cfg.Queries.GetVisibleLibraryMediaCounts(c.Request().Context(), uuidToPGType(userU))
|
||||
if countErr != nil {
|
||||
@@ -143,7 +185,7 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
|
||||
} else {
|
||||
anyFolderExists := false
|
||||
for _, folder := range folders {
|
||||
if _, statErr := os.Stat(folder.FolderPath); statErr == nil {
|
||||
if folderReachableCached(folder.FolderPath) {
|
||||
anyFolderExists = true
|
||||
break
|
||||
}
|
||||
@@ -189,8 +231,46 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
|
||||
return res
|
||||
}
|
||||
|
||||
// A requested library outside the requester's visible set is not
|
||||
// honored — fall back to the first visible library instead of leaking
|
||||
// hidden-library contents through a hand-typed query param or cookie.
|
||||
if !visibleSet[libraryID] {
|
||||
res.LibraryID = res.FirstID
|
||||
if res.LibraryID != "" {
|
||||
parsed, _ := uuid.Parse(res.LibraryID)
|
||||
res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
res.LibraryID = libraryID
|
||||
parsed, _ := uuid.Parse(libraryID)
|
||||
res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
return res
|
||||
}
|
||||
|
||||
// visibleLibraryIDsFor returns the requester's visible-library id set (for
|
||||
// membership checks) plus the ids themselves (for SQL ANY() filters).
|
||||
func visibleLibraryIDsFor(c *echo.Context, cfg *Config) (map[string]bool, []pgtype.UUID, error) { userUUIDStr, ok := c.Get("user_id").(string)
|
||||
if !ok {
|
||||
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
||||
}
|
||||
userU, err := uuid.Parse(userUUIDStr)
|
||||
if err != nil {
|
||||
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
||||
}
|
||||
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userU))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
set := make(map[string]bool, len(libraries))
|
||||
ids := make([]pgtype.UUID, 0, len(libraries))
|
||||
for _, lib := range libraries {
|
||||
if !lib.ID.Valid {
|
||||
continue
|
||||
}
|
||||
set[lib.ID.String()] = true
|
||||
ids = append(ids, lib.ID)
|
||||
}
|
||||
return set, ids, nil
|
||||
}
|
||||
+59
-36
@@ -52,6 +52,8 @@ type Config struct {
|
||||
WSHandler *handlers.WSHandler
|
||||
ConflictHandler *handlers.ConflictHandler
|
||||
AnalyticsHandler *handlers.AnalyticsHandler
|
||||
StatsHandler *handlers.StatsHandler
|
||||
StatisticsService *services.StatisticsService
|
||||
QueueHandler *handlers.QueueHandler
|
||||
CollectionHandler *handlers.CollectionHandler
|
||||
Worker *services.Worker
|
||||
@@ -113,11 +115,41 @@ func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid user ID in token")
|
||||
}
|
||||
|
||||
// Ghost-session guard: a signature-valid token for a user that
|
||||
// no longer exists must not authenticate (deleted users kept
|
||||
// full access until token expiry before this check). A
|
||||
// primary-key lookup per request — instant revocation, no TTL
|
||||
// semantics; the guard also covers the SSR groups that used to
|
||||
// chain ensureUserExistsMiddleware. The fetched row also feeds
|
||||
// the context stub: the user's timezone must travel with every
|
||||
// request so server-side time rendering converts per-user.
|
||||
liveUser, exists := fetchLiveUser(cfg, userUUID)
|
||||
if !exists {
|
||||
log.Printf("Ghost session rejected: user %s no longer exists", userIDStr)
|
||||
c.SetCookie(&http.Cookie{
|
||||
Name: "token",
|
||||
Value: "",
|
||||
Expires: time.Now().Add(-24 * time.Hour),
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
if wantsHTML(c.Request().Header) {
|
||||
return c.Redirect(http.StatusFound, "/login?session=invalid")
|
||||
}
|
||||
// Non-nil error is REQUIRED: returning c.JSON's nil would
|
||||
// let the chain continue into handlers whose c.Get("user")
|
||||
// assertion panics on the never-set user.
|
||||
return echo.NewHTTPError(http.StatusUnauthorized,
|
||||
"user_not_found: this account no longer exists; please log in again")
|
||||
}
|
||||
|
||||
c.Set("user", database.Users{
|
||||
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Email: claims["user_email"].(string),
|
||||
Username: claims["user_username"].(string),
|
||||
Role: claims["user_role"].(string),
|
||||
Timezone: liveUser.Timezone,
|
||||
})
|
||||
return nil
|
||||
},
|
||||
@@ -138,43 +170,19 @@ func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||
})
|
||||
}
|
||||
|
||||
// ensureUserExistsMiddleware checks if the authenticated user still exists in the database
|
||||
func ensureUserExistsMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
userIDStr, ok := c.Get("user_id").(string)
|
||||
if !ok {
|
||||
return next(c)
|
||||
}
|
||||
// userExists checks the users table for the token's claimed user. Runs on
|
||||
// every authenticated request: a primary-key lookup, cheap at this scale,
|
||||
// and it makes deletion revocable the moment it happens.
|
||||
func userExists(cfg *Config, id uuid.UUID) bool {
|
||||
_, err := cfg.Queries.GetUser(context.Background(), uuidToPGType(id))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
userUUID, err := uuid.Parse(userIDStr)
|
||||
if err != nil {
|
||||
log.Printf("Invalid UUID in user existence check: %v", err)
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Check if user exists in database
|
||||
_, err = cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(userUUID))
|
||||
if err != nil {
|
||||
log.Printf("User not found in database: %s", userIDStr)
|
||||
|
||||
// Clear invalid cookie
|
||||
c.SetCookie(&http.Cookie{
|
||||
Name: "token",
|
||||
Value: "",
|
||||
Expires: time.Now().Add(-24 * time.Hour),
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
// Redirect to login with session=invalid
|
||||
return c.Redirect(http.StatusFound, "/login?session=invalid")
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
// fetchLiveUser loads the full user row for the JWT stub — the ghost-session
|
||||
// guard and the timezone travel together on this one per-request lookup.
|
||||
func fetchLiveUser(cfg *Config, id uuid.UUID) (database.GetUserRow, bool) {
|
||||
u, err := cfg.Queries.GetUser(context.Background(), uuidToPGType(id))
|
||||
return u, err == nil
|
||||
}
|
||||
|
||||
// wantsHTML determines if the request expects HTML response
|
||||
@@ -255,6 +263,21 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
||||
registerWebSocketRoutes(cfg)
|
||||
registerFrontendRoutes(cfg)
|
||||
registerDocumentationRoutes(cfg)
|
||||
|
||||
// Static bundles are served unhashed (reader.js, index.js), so the
|
||||
// browser must revalidate on every load — with no Cache-Control it
|
||||
// fell back to heuristic caching and served stale bundles for hours
|
||||
// after a deploy (user-visible: fixed web bugs "not fixed"). no-cache
|
||||
// still allows cheap If-Modified-Since 304 revalidation.
|
||||
e.Pre(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
if strings.HasPrefix(c.Request().URL.Path, "/static/") {
|
||||
c.Response().Header().Set(echo.HeaderCacheControl, "no-cache")
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
})
|
||||
|
||||
e.Static("/static", "web/static")
|
||||
|
||||
// Catch-all 404 handler - must be last
|
||||
|
||||
@@ -4,13 +4,19 @@ import (
|
||||
"bookhoard/internal/database"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// ErrCollectionNameTaken: the user already has a collection with this name.
|
||||
var ErrCollectionNameTaken = errors.New("collection name already exists")
|
||||
|
||||
// Rule represents an auto-assign rule for collections
|
||||
type Rule struct {
|
||||
ID string `json:"id"`
|
||||
@@ -18,8 +24,30 @@ type Rule struct {
|
||||
Operator string `json:"operator"` // "equals", "contains", "starts_with", "ends_with", "greater_than", "less_than"
|
||||
Value string `json:"value"`
|
||||
Priority int `json:"priority"` // 1-10, higher values take precedence
|
||||
// Enabled gates the rule in the LIVE evaluation used by smart
|
||||
// collections; absent (JSON omitempty) means enabled — rules written
|
||||
// before the field existed keep matching.
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// Known rule fields and operators — the rules builder UI and the
|
||||
// persisted JSONB documents must agree with this vocabulary.
|
||||
var ruleFields = map[string]bool{
|
||||
"genre": true, "series": true, "author": true, "language": true,
|
||||
"publisher": true, "copyright_year": true, "tags": true,
|
||||
}
|
||||
|
||||
var ruleOperators = map[string]bool{
|
||||
"equals": true, "not_equals": true, "contains": true, "not_contains": true,
|
||||
"starts_with": true, "ends_with": true, "greater_than": true, "less_than": true,
|
||||
}
|
||||
|
||||
// RuleValidationError reports a rule payload the evaluator could never
|
||||
// honor (unknown field/operator, empty value).
|
||||
type RuleValidationError struct{ Msg string }
|
||||
|
||||
func (e *RuleValidationError) Error() string { return e.Msg }
|
||||
|
||||
// RuleEvaluation represents the result of evaluating a rule
|
||||
type RuleEvaluation struct {
|
||||
RuleID string `json:"rule_id"`
|
||||
@@ -72,7 +100,13 @@ func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UU
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return database.Collections{}, fmt.Errorf("failed to create collection: %v", err)
|
||||
// Duplicate name (UNIQUE(user_id, name)) is a client error, not a
|
||||
// server failure: surface it as 409 so callers can recover.
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return database.Collections{}, ErrCollectionNameTaken
|
||||
}
|
||||
return database.Collections{}, fmt.Errorf("failed to create collection: %w", err)
|
||||
}
|
||||
|
||||
return collection, nil
|
||||
@@ -160,9 +194,24 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
|
||||
|
||||
// DeleteCollection deletes a collection
|
||||
func (s *CollectionService) DeleteCollection(ctx context.Context, collectionID uuid.UUID) error {
|
||||
err := s.db.DeleteCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
||||
// System collections (the 5 dashboard sections) are PERMANENT: a
|
||||
// delete tombstones the row (invisible everywhere, restorable with
|
||||
// the same id) instead of destroying it and every reference to it.
|
||||
row, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete collection: %v", err)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil // already gone: keep delete idempotent
|
||||
}
|
||||
return fmt.Errorf("failed to fetch collection: %w", err)
|
||||
}
|
||||
|
||||
if row.IsSystemCollection.Bool {
|
||||
return s.db.TombstoneSystemCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
||||
}
|
||||
|
||||
err = s.db.DeleteCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete collection: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -217,6 +266,248 @@ func (s *CollectionService) GetBookCollections(ctx context.Context, mediaItemID
|
||||
return collections, nil
|
||||
}
|
||||
|
||||
// ParseRules decodes a collection's auto_assign_rules JSONB into typed
|
||||
// rules. "null", empty, and malformed docs mean "no rules".
|
||||
func (s *CollectionService) ParseRules(raw []byte) []Rule {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var rules []Rule
|
||||
if err := json.Unmarshal(raw, &rules); err != nil {
|
||||
return nil
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
// IsSmartCollection derives the collection type: a non-empty ruleset
|
||||
// makes it SMART (live-matched), an empty/absent ruleset makes it STATIC
|
||||
// (stored collection_items links).
|
||||
func (s *CollectionService) IsSmartCollection(raw []byte) bool {
|
||||
return len(s.ParseRules(raw)) > 0
|
||||
}
|
||||
|
||||
// EnabledRules filters a ruleset down to the rules that participate in
|
||||
// live evaluation (absent enabled = enabled for backward compatibility).
|
||||
func (s *CollectionService) EnabledRules(rules []Rule) []Rule {
|
||||
enabled := make([]Rule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
if rule.Enabled == nil || *rule.Enabled {
|
||||
enabled = append(enabled, rule)
|
||||
}
|
||||
}
|
||||
return enabled
|
||||
}
|
||||
|
||||
// validateRule checks a rule payload the evaluator will have to honor.
|
||||
func (s *CollectionService) validateRule(rule Rule) error {
|
||||
if !ruleFields[rule.Field] {
|
||||
return &RuleValidationError{Msg: "unknown rule field: " + rule.Field}
|
||||
}
|
||||
if !ruleOperators[rule.Operator] {
|
||||
return &RuleValidationError{Msg: "unknown rule operator: " + rule.Operator}
|
||||
}
|
||||
if strings.TrimSpace(rule.Value) == "" {
|
||||
return &RuleValidationError{Msg: "rule value is required"}
|
||||
}
|
||||
if rule.Priority < 0 || rule.Priority > 10 {
|
||||
return &RuleValidationError{Msg: "priority must be between 0 and 10"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddRule appends a rule to the collection's ruleset, generating an id
|
||||
// when the caller did not supply one. Adding the first rule CONVERTS a
|
||||
// static collection to smart (type is derived from rules presence).
|
||||
func (s *CollectionService) AddRule(ctx context.Context, collectionID uuid.UUID, rule Rule) (database.Collections, Rule, error) {
|
||||
if err := s.validateRule(rule); err != nil {
|
||||
return database.Collections{}, Rule{}, err
|
||||
}
|
||||
|
||||
collection, err := s.GetCollection(ctx, collectionID)
|
||||
if err != nil {
|
||||
return database.Collections{}, Rule{}, err
|
||||
}
|
||||
|
||||
rules := s.ParseRules(collection.AutoAssignRules)
|
||||
if rule.ID == "" {
|
||||
rule.ID = uuid.NewString()
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
|
||||
rulesJSON, marshalErr := json.Marshal(rules)
|
||||
if marshalErr != nil {
|
||||
return database.Collections{}, Rule{}, fmt.Errorf("failed to marshal rules: %w", marshalErr)
|
||||
}
|
||||
|
||||
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
AutoAssignRules: rulesJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return database.Collections{}, Rule{}, fmt.Errorf("failed to save rules: %w", err)
|
||||
}
|
||||
return updated, rule, nil
|
||||
}
|
||||
|
||||
// SetRuleEnabled flips a single rule's enabled flag in place.
|
||||
func (s *CollectionService) SetRuleEnabled(ctx context.Context, collectionID uuid.UUID, ruleID string, enabled bool) (database.Collections, error) {
|
||||
collection, err := s.GetCollection(ctx, collectionID)
|
||||
if err != nil {
|
||||
return database.Collections{}, err
|
||||
}
|
||||
rules := s.ParseRules(collection.AutoAssignRules)
|
||||
found := false
|
||||
for i := range rules {
|
||||
if rules[i].ID == ruleID {
|
||||
rules[i].Enabled = &enabled
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return database.Collections{}, fmt.Errorf("rule not found: %s", ruleID)
|
||||
}
|
||||
|
||||
rulesJSON, marshalErr := json.Marshal(rules)
|
||||
if marshalErr != nil {
|
||||
return database.Collections{}, fmt.Errorf("failed to marshal rules: %w", marshalErr)
|
||||
}
|
||||
|
||||
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
AutoAssignRules: rulesJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return database.Collections{}, fmt.Errorf("failed to save rules: %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// RemoveRule deletes a single rule from the ruleset. Removing the LAST
|
||||
// rule converts the collection back to static (type is derived).
|
||||
func (s *CollectionService) RemoveRule(ctx context.Context, collectionID uuid.UUID, ruleID string) (database.Collections, error) {
|
||||
collection, err := s.GetCollection(ctx, collectionID)
|
||||
if err != nil {
|
||||
return database.Collections{}, err
|
||||
}
|
||||
rules := s.ParseRules(collection.AutoAssignRules)
|
||||
kept := make([]Rule, 0, len(rules))
|
||||
found := false
|
||||
for _, rule := range rules {
|
||||
if rule.ID == ruleID {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, rule)
|
||||
}
|
||||
if !found {
|
||||
return database.Collections{}, fmt.Errorf("rule not found: %s", ruleID)
|
||||
}
|
||||
|
||||
rulesJSON, marshalErr := json.Marshal(kept)
|
||||
if marshalErr != nil {
|
||||
return database.Collections{}, fmt.Errorf("failed to marshal rules: %w", marshalErr)
|
||||
}
|
||||
|
||||
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
AutoAssignRules: rulesJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return database.Collections{}, fmt.Errorf("failed to save rules: %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// SmartEvalOptions scopes a smart collection's live evaluation.
|
||||
type SmartEvalOptions struct {
|
||||
// LibraryID, when valid, restricts matching to one library (the
|
||||
// detail page's library switcher).
|
||||
LibraryID pgtype.UUID
|
||||
// VisibleIDs is the requesting user's visible-library set (family
|
||||
// lens). Empty means no visible-library filter.
|
||||
VisibleIDs []pgtype.UUID
|
||||
}
|
||||
|
||||
// EvaluateSmartCollection resolves a smart collection's membership LIVE:
|
||||
// every visible live book matched by at least one enabled rule is in.
|
||||
// No stored links are consulted — "truly dynamic," always current.
|
||||
func (s *CollectionService) EvaluateSmartCollection(ctx context.Context, rules []Rule, opts SmartEvalOptions) ([]database.MediaItems, error) {
|
||||
enabled := s.EnabledRules(rules)
|
||||
if len(enabled) == 0 {
|
||||
return []database.MediaItems{}, nil
|
||||
}
|
||||
|
||||
var visibleArg []pgtype.UUID
|
||||
if len(opts.VisibleIDs) > 0 {
|
||||
visibleArg = opts.VisibleIDs
|
||||
}
|
||||
items, err := s.db.GetVisibleLiveMediaForRules(ctx, database.GetVisibleLiveMediaForRulesParams{
|
||||
LibraryID: opts.LibraryID,
|
||||
VisibleLibraryIds: visibleArg,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load candidate books: %w", err)
|
||||
}
|
||||
|
||||
matches := make([]database.MediaItems, 0, len(items))
|
||||
for _, item := range items {
|
||||
if s.mediaItemMatchesAnyRule(item, enabled) {
|
||||
matches = append(matches, item)
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func (s *CollectionService) mediaItemMatchesAnyRule(item database.MediaItems, rules []Rule) bool {
|
||||
for _, rule := range rules {
|
||||
if s.mediaItemMatchesRule(item, rule) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *CollectionService) mediaItemMatchesRule(item database.MediaItems, rule Rule) bool {
|
||||
if rule.Enabled != nil && !*rule.Enabled {
|
||||
return false
|
||||
}
|
||||
|
||||
var fieldValue string
|
||||
switch rule.Field {
|
||||
case "genre":
|
||||
if item.Genre.Valid {
|
||||
fieldValue = item.Genre.String
|
||||
}
|
||||
case "series":
|
||||
if item.Series.Valid {
|
||||
fieldValue = item.Series.String
|
||||
}
|
||||
case "author":
|
||||
if item.Author.Valid {
|
||||
fieldValue = item.Author.String
|
||||
}
|
||||
case "language":
|
||||
if item.Language.Valid {
|
||||
fieldValue = item.Language.String
|
||||
}
|
||||
case "publisher":
|
||||
if item.Publisher.Valid {
|
||||
fieldValue = item.Publisher.String
|
||||
}
|
||||
case "copyright_year":
|
||||
if item.CopyrightYear.Valid {
|
||||
fieldValue = fmt.Sprintf("%d", item.CopyrightYear.Int32)
|
||||
}
|
||||
case "tags":
|
||||
if len(item.Tags) > 0 {
|
||||
fieldValue = strings.Join(item.Tags, ", ")
|
||||
}
|
||||
}
|
||||
|
||||
return s.evaluateRule(fieldValue, rule.Operator, rule.Value)
|
||||
}
|
||||
|
||||
// EvaluateRules evaluates auto-assign rules for a media item
|
||||
func (s *CollectionService) EvaluateRules(mediaItem database.ListMediaItemsRow, rules []Rule) []RuleEvaluation {
|
||||
var evaluations []RuleEvaluation
|
||||
@@ -233,6 +524,11 @@ func (s *CollectionService) EvaluateRules(mediaItem database.ListMediaItemsRow,
|
||||
}
|
||||
|
||||
for _, rule := range sortedRules {
|
||||
// Disabled rules never match (kept for context in the UI only).
|
||||
if rule.Enabled != nil && !*rule.Enabled {
|
||||
evaluations = append(evaluations, RuleEvaluation{RuleID: rule.ID, Matches: false})
|
||||
continue
|
||||
}
|
||||
eval := RuleEvaluation{
|
||||
RuleID: rule.ID,
|
||||
Matches: false,
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
@@ -376,6 +378,22 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data
|
||||
}
|
||||
|
||||
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID uuid.UUID, libraryID pgtype.UUID) (database.UserDashboardPreferences, error) {
|
||||
// Absent library = the global (All Libraries) scope: a separate
|
||||
// one-row-per-user table, so an accidental per-library write can never
|
||||
// masquerade as global.
|
||||
if !libraryID.Valid {
|
||||
g, err := s.db.GetGlobalDashboardPreferences(ctx, pgtype.UUID{Bytes: userID, Valid: true})
|
||||
if err != nil {
|
||||
return database.UserDashboardPreferences{}, err
|
||||
}
|
||||
return database.UserDashboardPreferences{
|
||||
UserID: g.UserID,
|
||||
HiddenCollections: g.HiddenCollections,
|
||||
CollectionOrder: g.CollectionOrder,
|
||||
ItemsPerSection: g.ItemsPerSection,
|
||||
UpdatedAt: g.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
LibraryID: libraryID,
|
||||
@@ -383,6 +401,24 @@ func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID u
|
||||
}
|
||||
|
||||
func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, params database.UpsertDashboardPreferencesParams) (database.UserDashboardPreferences, error) {
|
||||
if !params.LibraryID.Valid {
|
||||
g, err := s.db.UpsertGlobalDashboardPreferences(ctx, database.UpsertGlobalDashboardPreferencesParams{
|
||||
UserID: params.UserID,
|
||||
HiddenCollections: params.HiddenCollections,
|
||||
CollectionOrder: params.CollectionOrder,
|
||||
ItemsPerSection: params.ItemsPerSection,
|
||||
})
|
||||
if err != nil {
|
||||
return database.UserDashboardPreferences{}, err
|
||||
}
|
||||
return database.UserDashboardPreferences{
|
||||
UserID: g.UserID,
|
||||
HiddenCollections: g.HiddenCollections,
|
||||
CollectionOrder: g.CollectionOrder,
|
||||
ItemsPerSection: g.ItemsPerSection,
|
||||
UpdatedAt: g.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
return s.db.UpsertDashboardPreferences(ctx, params)
|
||||
}
|
||||
|
||||
@@ -430,26 +466,23 @@ func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID u
|
||||
return fmt.Errorf("unknown collection: %s", collectionName)
|
||||
}
|
||||
|
||||
if resetType == "keep_books" {
|
||||
return s.db.ResetSystemCollectionMetadata(ctx, database.ResetSystemCollectionMetadataParams{
|
||||
// System collections are PERMANENT rows identified by the stable
|
||||
// (user_id, query_type) pair — restore never deletes or re-creates,
|
||||
// so the row id (and every reference to it: device shelf mappings,
|
||||
// dashboard order/hidden prefs, /collections/{id} links) never churns.
|
||||
// Resolution includes tombstoned rows: restoring a deleted section
|
||||
// un-deletes the exact same row.
|
||||
row, err := s.db.GetSystemCollectionByQueryType(ctx, database.GetSystemCollectionByQueryTypeParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Name: collectionName,
|
||||
Description: pgtype.Text{String: meta.Description, Valid: true},
|
||||
Icon: pgtype.Text{String: meta.Icon, Valid: true},
|
||||
Color: pgtype.Text{String: meta.Color, Valid: true},
|
||||
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
|
||||
QueryType: pgtype.Text{String: meta.QueryType, Valid: true},
|
||||
})
|
||||
}
|
||||
|
||||
err := s.db.DeleteUserSystemCollection(ctx, database.DeleteUserSystemCollectionParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Name: collectionName,
|
||||
})
|
||||
if err != nil {
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// Provision: the only path that mints a system-collection row.
|
||||
_, cerr := s.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Name: collectionName,
|
||||
Description: pgtype.Text{String: meta.Description, Valid: true},
|
||||
@@ -459,5 +492,24 @@ func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID u
|
||||
QueryType: pgtype.Text{String: meta.QueryType, Valid: true},
|
||||
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
|
||||
})
|
||||
return cerr
|
||||
}
|
||||
|
||||
// "full": clear item assignments (a fresh section has none) and reset
|
||||
// everything, IN PLACE. "keep_books": metadata-only, in place. Both
|
||||
// un-tombstone, so a deleted section comes back as the same row.
|
||||
if resetType != "keep_books" {
|
||||
if err := s.db.ClearCollectionItems(ctx, row.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = s.db.RestoreSystemCollectionRow(ctx, database.RestoreSystemCollectionRowParams{
|
||||
ID: row.ID,
|
||||
Name: collectionName,
|
||||
Description: pgtype.Text{String: meta.Description, Valid: true},
|
||||
Icon: pgtype.Text{String: meta.Icon, Valid: true},
|
||||
Color: pgtype.Text{String: meta.Color, Valid: true},
|
||||
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -299,6 +299,13 @@ func (s *LibraryService) SyncAllowedExtensions(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (s *LibraryService) ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
|
||||
// Typed-nil interface guard: a nil *LibraryService held in an
|
||||
// interface passes `!= nil` checks at call sites; degrade instead
|
||||
// of panicking (kobo markup 500s were exactly this path).
|
||||
if s == nil || s.db == nil {
|
||||
return "", fmt.Errorf("library service not configured")
|
||||
}
|
||||
|
||||
// Get library folders for this library
|
||||
folders, err := s.db.GetLibraryFolders(ctx, libraryID)
|
||||
if err != nil || len(folders) == 0 {
|
||||
|
||||
@@ -5,11 +5,13 @@ import (
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type SeriesInfo struct {
|
||||
Name string
|
||||
LibraryID string
|
||||
BookCount int64
|
||||
TotalInSeries int
|
||||
CoverPaths []string
|
||||
@@ -24,14 +26,23 @@ func NewSeriesService(db *database.Queries) *SeriesService {
|
||||
return &SeriesService{db: db}
|
||||
}
|
||||
|
||||
func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID, limit, offset int) ([]SeriesInfo, int, error) {
|
||||
totalCount, err := s.db.GetDistinctSeriesCount(ctx, libraryID)
|
||||
// GetSeriesPage lists series grouped PER LIBRARY (same name in two
|
||||
// libraries = two entries) filtered to the requester's visible libraries.
|
||||
// Pass libraryID for a single-library page, or leave it invalid and pass
|
||||
// visibleLibraryIDs for an across-my-libraries page. Both filters compose:
|
||||
// the handler validates any explicit library_id against the visible set.
|
||||
func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int) ([]SeriesInfo, int, error) {
|
||||
totalCount, err := s.db.GetDistinctSeriesCount(ctx, database.GetDistinctSeriesCountParams{
|
||||
LibraryID: libraryID,
|
||||
VisibleLibraryIds: visibleLibraryIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rows, err := s.db.GetDistinctSeries(ctx, database.GetDistinctSeriesParams{
|
||||
LibraryID: libraryID,
|
||||
VisibleLibraryIds: visibleLibraryIDs,
|
||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||
Offset: pgtype.Int4{Int32: int32(offset), Valid: true},
|
||||
})
|
||||
@@ -42,7 +53,7 @@ func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID
|
||||
var series []SeriesInfo
|
||||
for _, row := range rows {
|
||||
seriesName := row.Series.String
|
||||
coverPaths, _ := s.GetSeriesCovers(ctx, libraryID, seriesName, 7)
|
||||
coverPaths, _ := s.GetSeriesCovers(ctx, row.LibraryID, seriesName, 7)
|
||||
|
||||
totalInSeries := 0
|
||||
if row.TotalInSeries != nil {
|
||||
@@ -71,6 +82,7 @@ func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID
|
||||
|
||||
series = append(series, SeriesInfo{
|
||||
Name: seriesName,
|
||||
LibraryID: uuidFromPG(row.LibraryID),
|
||||
BookCount: row.BookCount,
|
||||
TotalInSeries: totalInSeries,
|
||||
CoverPaths: coverPaths,
|
||||
@@ -106,8 +118,28 @@ func (s *SeriesService) GetSeriesCovers(ctx context.Context, libraryID pgtype.UU
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func (s *SeriesService) GetSeriesBooks(ctx context.Context, seriesName string) ([]database.MediaItems, error) {
|
||||
return s.db.GetSeriesBooks(ctx, pgtype.Text{String: seriesName, Valid: true})
|
||||
func (s *SeriesService) GetSeriesBooks(ctx context.Context, libraryID pgtype.UUID, seriesName string) ([]database.MediaItems, error) {
|
||||
return s.db.GetSeriesBooks(ctx, database.GetSeriesBooksParams{
|
||||
Series: pgtype.Text{String: seriesName, Valid: true},
|
||||
LibraryID: libraryID,
|
||||
})
|
||||
}
|
||||
|
||||
// VisibleLibraries exposes the per-user visibility set used by the series
|
||||
// handlers to scope listings and validate explicit library_id params.
|
||||
func (s *SeriesService) VisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]database.GetUserVisibleLibrariesRow, error) {
|
||||
return s.db.GetUserVisibleLibraries(ctx, userID)
|
||||
}
|
||||
|
||||
func uuidFromPG(id pgtype.UUID) string {
|
||||
if !id.Valid {
|
||||
return ""
|
||||
}
|
||||
u, err := uuid.FromBytes(id.Bytes[0:16])
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func continueSeriesRowToMediaItems(row database.GetContinueSeriesItemsRow) database.MediaItems {
|
||||
|
||||
@@ -0,0 +1,669 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// StatisticsService aggregates reading_history sessions into the
|
||||
// reading-stats surfaces: the /stats page, its per-book card, and the
|
||||
// dashboard "this week" widget. All day bucketing happens in the
|
||||
// REQUESTING user's timezone — every calendar concept here is per-user
|
||||
// (server convention; "today" is the user's calendar, never UTC).
|
||||
type StatisticsService struct {
|
||||
db *database.Queries
|
||||
}
|
||||
|
||||
func NewStatisticsService(db *database.Queries) *StatisticsService {
|
||||
return &StatisticsService{db: db}
|
||||
}
|
||||
|
||||
// finishedThreshold mirrors what the readers push at the end of a book:
|
||||
// 0.995 leaves room for a TOC/colophon page pushing just under 100%
|
||||
// (percentage is a 0..1 float everywhere on the server).
|
||||
const finishedThreshold = 0.995
|
||||
|
||||
// streakLookbackDays bounds the streak calendar (both streaks computed
|
||||
// over that recent window — "in record").
|
||||
const streakLookbackDays = 730
|
||||
|
||||
// topBooksLimit is the "most read" list length on the /stats page.
|
||||
const topBooksLimit = 5
|
||||
|
||||
// StatsRange is an inclusive calendar range in a user's timezone.
|
||||
type StatsRange struct {
|
||||
Start time.Time // first instant of the first day
|
||||
End time.Time // last nanosecond of the last day
|
||||
Days int
|
||||
}
|
||||
|
||||
// ResolveStatsRange maps the UI's selector ("7", "30", "90", "year") to
|
||||
// an inclusive window ending today in the user's timezone.
|
||||
func ResolveStatsRange(tz string, rangeKey string) (StatsRange, error) {
|
||||
loc := utils.UserLocation(tz)
|
||||
now := time.Now().In(loc)
|
||||
// Anchor the end-of-day to the START of today (clock time stripped):
|
||||
// today.AddDate(0,0,1) minus a nanosecond would carry this instant's
|
||||
// clock into tomorrow and resolve "today" a calendar day late.
|
||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||
end := todayStart.AddDate(0, 0, 1).Add(-time.Nanosecond)
|
||||
|
||||
var days int
|
||||
switch rangeKey {
|
||||
case "", "7":
|
||||
days = 7
|
||||
case "30":
|
||||
days = 30
|
||||
case "90":
|
||||
days = 90
|
||||
case "year":
|
||||
// Calendar year to date (Jan 1 → today).
|
||||
jan1 := time.Date(now.Year(), time.January, 1, 0, 0, 0, 0, loc)
|
||||
days = int(now.Sub(jan1).Hours()/24.0) + 1
|
||||
if days < 1 {
|
||||
days = 1
|
||||
}
|
||||
default:
|
||||
return StatsRange{}, fmt.Errorf("invalid range: %s (use 7, 30, 90 or year)", rangeKey)
|
||||
}
|
||||
|
||||
start := todayStart.AddDate(0, 0, -(days - 1))
|
||||
return StatsRange{Start: start, End: end, Days: days}, nil
|
||||
}
|
||||
|
||||
type DailyStats struct {
|
||||
Date string `json:"date"` // YYYY-MM-DD, in the user's timezone
|
||||
Minutes int `json:"minutes"`
|
||||
Pages int `json:"pages"`
|
||||
Sessions int `json:"sessions"`
|
||||
}
|
||||
|
||||
type StreakStats struct {
|
||||
// Current counts consecutive active days ending at today; when today
|
||||
// has no session yet, the streak that ends yesterday still counts.
|
||||
Current int `json:"current"`
|
||||
Longest int `json:"longest"` // longest active streak in record
|
||||
ActiveToday bool `json:"active_today"`
|
||||
}
|
||||
|
||||
type TopBookStats struct {
|
||||
MediaItemID string `json:"media_item_id"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
CoverImagePath string `json:"cover_image_path"`
|
||||
Minutes int `json:"minutes"`
|
||||
Pages int `json:"pages"`
|
||||
Sessions int `json:"sessions"`
|
||||
LastRead string `json:"last_read"`
|
||||
}
|
||||
|
||||
type LibraryStats struct {
|
||||
LibraryID string `json:"library_id"`
|
||||
Name string `json:"name"`
|
||||
Minutes int `json:"minutes"`
|
||||
Pages int `json:"pages"`
|
||||
Sessions int `json:"sessions"`
|
||||
Books int `json:"books"`
|
||||
}
|
||||
|
||||
type DeviceStats struct {
|
||||
// DeviceID is empty for the web bucket (pushes that carry no device
|
||||
// credential — browser reading sessions) — Name then reads "Web".
|
||||
DeviceID string `json:"device_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Minutes int `json:"minutes"`
|
||||
Pages int `json:"pages"`
|
||||
Sessions int `json:"sessions"`
|
||||
Books int `json:"books"`
|
||||
}
|
||||
|
||||
type UserSummaryStats struct {
|
||||
Range string `json:"range"`
|
||||
Days int `json:"days"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
TotalMinutes int `json:"total_minutes"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
SessionCount int `json:"session_count"`
|
||||
BooksActive int `json:"books_with_activity"`
|
||||
BooksFinished int `json:"books_finished"`
|
||||
CompletionRate float64 `json:"completion_rate"` // finished / books with activity (0 when none)
|
||||
AvgSessionMin float64 `json:"average_session_minutes"`
|
||||
Daily []DailyStats `json:"daily"` // zero-filled across the range
|
||||
Streak StreakStats `json:"streak"`
|
||||
MostActiveWeekday string `json:"most_active_weekday"` // by minutes over the range; "" when nothing
|
||||
MostActiveDay string `json:"most_active_day"` // date with the most minutes
|
||||
MostActiveMinutes int `json:"most_active_day_minutes"`
|
||||
TopBooks []TopBookStats `json:"top_books"`
|
||||
LibraryBreakdown []LibraryStats `json:"library_breakdown"`
|
||||
DeviceBreakdown []DeviceStats `json:"device_breakdown"`
|
||||
}
|
||||
|
||||
type WeekSummaryStats struct {
|
||||
WeekMinutes int `json:"week_minutes"`
|
||||
WeekPages int `json:"week_pages"`
|
||||
WeekSessions int `json:"week_sessions"`
|
||||
WeekBooks int `json:"week_books"`
|
||||
PrevMinutes int `json:"prev_week_minutes"`
|
||||
PrevPages int `json:"prev_week_pages"`
|
||||
PrevSessions int `json:"prev_week_sessions"`
|
||||
ActiveToday bool `json:"active_today"`
|
||||
CurrentStreak int `json:"current_streak"`
|
||||
}
|
||||
|
||||
type BookStats struct {
|
||||
MediaItemID string `json:"media_item_id"`
|
||||
TotalMinutes int `json:"total_minutes"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
SessionCount int `json:"session_count"`
|
||||
MaxProgress float64 `json:"max_progress"`
|
||||
Finished bool `json:"finished"`
|
||||
FirstSession string `json:"first_session"`
|
||||
LastSession string `json:"last_session"`
|
||||
RecentSessions []SessionSummary `json:"recent_sessions"` // newest first, capped
|
||||
}
|
||||
|
||||
type SessionSummary struct {
|
||||
StartedAt string `json:"started_at"`
|
||||
EndedAt string `json:"ended_at"`
|
||||
Minutes int `json:"minutes"`
|
||||
Pages int `json:"pages"` // 0 when unknown (no page info on the pushes)
|
||||
Progress float64 `json:"progress"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// userVisibleLibraryIDs loads the family-lens set for this user (empty →
|
||||
// no visible-library filter, i.e. everything is visible by default).
|
||||
func (s *StatisticsService) userVisibleLibraryIDs(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
|
||||
rows, err := s.db.GetUserVisibleLibraries(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check library access: %w", err)
|
||||
}
|
||||
ids := make([]pgtype.UUID, 0, len(rows))
|
||||
for _, lib := range rows {
|
||||
if lib.ID.Valid {
|
||||
ids = append(ids, lib.ID)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// visibleOrNil maps an empty visible set to a NULL filter argument.
|
||||
func visibleOrNil(ids []pgtype.UUID) []pgtype.UUID {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// UserSummary builds the /stats page payload for a range: totals,
|
||||
// zero-filled daily arrays, streaks, most-active day, top books, and
|
||||
// the per-library breakdown — one call for the page.
|
||||
func (s *StatisticsService) UserSummary(ctx context.Context, userID pgtype.UUID, tz, rangeKey string) (UserSummaryStats, error) {
|
||||
statsRange, err := ResolveStatsRange(tz, rangeKey)
|
||||
if err != nil {
|
||||
return UserSummaryStats{}, err
|
||||
}
|
||||
loc := utils.UserLocation(tz)
|
||||
|
||||
visibleIDs, err := s.userVisibleLibraryIDs(ctx, userID)
|
||||
if err != nil {
|
||||
return UserSummaryStats{}, err
|
||||
}
|
||||
|
||||
sessions, err := s.db.GetUserReadingSessionsRange(ctx, database.GetUserReadingSessionsRangeParams{
|
||||
UserID: userID,
|
||||
StartTime: pgtype.Timestamptz{Time: statsRange.Start, Valid: true},
|
||||
EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true},
|
||||
VisibleLibraryIds: visibleOrNil(visibleIDs),
|
||||
})
|
||||
if err != nil {
|
||||
return UserSummaryStats{}, fmt.Errorf("failed to load reading sessions: %w", err)
|
||||
}
|
||||
|
||||
summary := UserSummaryStats{
|
||||
Range: rangeKey,
|
||||
Days: statsRange.Days,
|
||||
StartDate: statsRange.Start.Format("2006-01-02"),
|
||||
EndDate: statsRange.End.Format("2006-01-02"),
|
||||
Daily: make([]DailyStats, 0, statsRange.Days),
|
||||
TopBooks: []TopBookStats{},
|
||||
LibraryBreakdown: []LibraryStats{},
|
||||
DeviceBreakdown: []DeviceStats{},
|
||||
}
|
||||
|
||||
// Zero-filled calendar (inclusive of today).
|
||||
dailyMap := make(map[string]*DailyStats, statsRange.Days)
|
||||
for i := 0; i < statsRange.Days; i++ {
|
||||
day := statsRange.Start.AddDate(0, 0, i)
|
||||
entry := DailyStats{Date: day.Format("2006-01-02")}
|
||||
summary.Daily = append(summary.Daily, entry)
|
||||
dailyMap[entry.Date] = &summary.Daily[len(summary.Daily)-1]
|
||||
}
|
||||
|
||||
bookActivity := make(map[string]bool, len(sessions))
|
||||
finishedSet := make(map[string]bool)
|
||||
var totalSessionSeconds int64
|
||||
weekdayMinutes := make(map[time.Weekday]int)
|
||||
|
||||
for _, sess := range sessions {
|
||||
// The bucket key matches the SQL range filter: the session END
|
||||
// instant (what the capture stamps; NULL falls back to
|
||||
// created_at) — not the row's created_at.
|
||||
ts := sess.CreatedAt
|
||||
if sess.ReadingSessionEnd.Valid {
|
||||
ts = sess.ReadingSessionEnd
|
||||
}
|
||||
key := dayKey(ts, loc)
|
||||
entry, ok := dailyMap[key]
|
||||
if !ok {
|
||||
continue // defensive: outside the inclusive window
|
||||
}
|
||||
|
||||
if sess.TimeSpentSeconds.Valid {
|
||||
seconds := int64(sess.TimeSpentSeconds.Int32)
|
||||
totalSessionSeconds += seconds
|
||||
summary.TotalMinutes += int(seconds / 60)
|
||||
entry.Minutes += int(seconds / 60)
|
||||
}
|
||||
if sess.PagesRead.Valid {
|
||||
summary.TotalPages += int(sess.PagesRead.Int32)
|
||||
entry.Pages += int(sess.PagesRead.Int32)
|
||||
}
|
||||
entry.Sessions++
|
||||
summary.SessionCount++
|
||||
|
||||
mediaUUID, err := uuid.FromBytes(sess.MediaItemID.Bytes[0:16])
|
||||
if err == nil {
|
||||
key := mediaUUID.String()
|
||||
bookActivity[key] = true
|
||||
if sess.ProgressPercentage.Valid && sess.ProgressPercentage.Float64 >= finishedThreshold {
|
||||
finishedSet[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
localTime := ts.Time.In(loc)
|
||||
if sess.TimeSpentSeconds.Valid {
|
||||
weekdayMinutes[localTime.Weekday()] += int(sess.TimeSpentSeconds.Int32) / 60
|
||||
}
|
||||
}
|
||||
|
||||
summary.BooksActive = len(bookActivity)
|
||||
summary.BooksFinished = len(finishedSet)
|
||||
if summary.BooksActive > 0 {
|
||||
summary.CompletionRate = float64(summary.BooksFinished) / float64(summary.BooksActive)
|
||||
}
|
||||
if summary.SessionCount > 0 {
|
||||
summary.AvgSessionMin = float64(totalSessionSeconds) / float64(summary.SessionCount) / 60.0
|
||||
}
|
||||
|
||||
// Most-active weekday by minutes (empty string before any signal).
|
||||
dayNames := []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
|
||||
bestWeekdayMinutes := -1
|
||||
var bestWeekday time.Weekday
|
||||
for wd, mins := range weekdayMinutes {
|
||||
if mins > bestWeekdayMinutes {
|
||||
bestWeekdayMinutes = mins
|
||||
bestWeekday = wd
|
||||
}
|
||||
}
|
||||
if bestWeekdayMinutes > 0 {
|
||||
summary.MostActiveWeekday = dayNames[bestWeekday]
|
||||
}
|
||||
|
||||
for _, d := range summary.Daily {
|
||||
if d.Minutes > summary.MostActiveMinutes {
|
||||
summary.MostActiveMinutes = d.Minutes
|
||||
summary.MostActiveDay = d.Date
|
||||
}
|
||||
}
|
||||
|
||||
// Streaks: a dedicated lightweight calendar (~2y look-back) so a
|
||||
// current streak longer than the selected range is still real.
|
||||
activeDays, err := s.db.GetUserReadingActiveDays(ctx, database.GetUserReadingActiveDaysParams{
|
||||
Tz: loc.String(),
|
||||
UserID: userID,
|
||||
StartTime: pgtype.Timestamptz{Time: statsRange.Start.AddDate(0, 0, -(streakLookbackDays - statsRange.Days)), Valid: true},
|
||||
VisibleLibraryIds: visibleOrNil(visibleIDs),
|
||||
})
|
||||
if err == nil {
|
||||
summary.Streak = computeStreaks(activeDays, statsRange.End.Format("2006-01-02"))
|
||||
} else {
|
||||
log.Printf("stats: active-days query failed: %v", err)
|
||||
}
|
||||
|
||||
// Top books by minutes in the range.
|
||||
topRows, err := s.db.GetTopBooksByMinutes(ctx, database.GetTopBooksByMinutesParams{
|
||||
UserID: userID,
|
||||
StartTime: pgtype.Timestamptz{Time: statsRange.Start, Valid: true},
|
||||
EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true},
|
||||
VisibleLibraryIds: visibleOrNil(visibleIDs),
|
||||
Limit: topBooksLimit,
|
||||
})
|
||||
if err == nil {
|
||||
for _, row := range topRows {
|
||||
entry := TopBookStats{
|
||||
Title: row.Title,
|
||||
Author: row.Author.String,
|
||||
CoverImagePath: utils.ResolveMediaURL(row.LibraryID, row.CoverImagePath),
|
||||
Minutes: int(row.TotalSeconds) / 60,
|
||||
Pages: int(row.TotalPages),
|
||||
Sessions: int(row.SessionCount),
|
||||
}
|
||||
if row.MediaItemID.Valid {
|
||||
entry.MediaItemID = uuid.UUID(row.MediaItemID.Bytes).String()
|
||||
}
|
||||
if row.LastRead != nil {
|
||||
if t, ok := row.LastRead.(time.Time); ok {
|
||||
entry.LastRead = utils.FormatTimeInTimezone(t, tz)
|
||||
}
|
||||
}
|
||||
summary.TopBooks = append(summary.TopBooks, entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Per-library breakdown in the range.
|
||||
libRows, err := s.db.GetUserLibraryBreakdown(ctx, database.GetUserLibraryBreakdownParams{
|
||||
UserID: userID,
|
||||
StartTime: pgtype.Timestamptz{Time: statsRange.Start, Valid: true},
|
||||
EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true},
|
||||
VisibleLibraryIds: visibleOrNil(visibleIDs),
|
||||
})
|
||||
if err == nil {
|
||||
for _, row := range libRows {
|
||||
entry := LibraryStats{
|
||||
Name: row.Name,
|
||||
Minutes: int(row.TotalSeconds) / 60,
|
||||
Pages: int(row.TotalPages),
|
||||
Sessions: int(row.SessionCount),
|
||||
Books: int(row.BookCount),
|
||||
}
|
||||
if row.LibraryID.Valid {
|
||||
entry.LibraryID = uuid.UUID(row.LibraryID.Bytes).String()
|
||||
}
|
||||
summary.LibraryBreakdown = append(summary.LibraryBreakdown, entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Per-device breakdown in the range (LEFT JOIN devices: NULL-device
|
||||
// pushes aggregate into one unattributed row so the panel reconciles
|
||||
// with the totals).
|
||||
deviceRows, err := s.db.GetUserDeviceBreakdown(ctx, database.GetUserDeviceBreakdownParams{
|
||||
UserID: userID,
|
||||
StartTime: pgtype.Timestamptz{Time: statsRange.Start, Valid: true},
|
||||
EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true},
|
||||
VisibleLibraryIds: visibleOrNil(visibleIDs),
|
||||
})
|
||||
if err == nil {
|
||||
for _, row := range deviceRows {
|
||||
entry := DeviceStats{
|
||||
Minutes: int(row.TotalSeconds) / 60,
|
||||
Pages: int(row.TotalPages),
|
||||
Sessions: int(row.SessionCount),
|
||||
Books: int(row.BookCount),
|
||||
Name: "Web",
|
||||
Type: "web",
|
||||
}
|
||||
if row.DeviceName.Valid && row.DeviceName.String != "" {
|
||||
entry.Name = row.DeviceName.String
|
||||
}
|
||||
if row.DeviceType.Valid && row.DeviceType.String != "" {
|
||||
entry.Type = row.DeviceType.String
|
||||
}
|
||||
if row.ID.Valid {
|
||||
entry.DeviceID = uuid.UUID(row.ID.Bytes).String()
|
||||
}
|
||||
summary.DeviceBreakdown = append(summary.DeviceBreakdown, entry)
|
||||
}
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// ThisWeek builds the dashboard widget: the current rolling 7-day
|
||||
// window (today inclusive) versus the previous seven days, plus the
|
||||
// streak context. One scan over 14 days, split in Go. libraryID scopes
|
||||
// everything to one library (invalid = all visible libraries) so the
|
||||
// widget tracks the dashboard's library switcher.
|
||||
func (s *StatisticsService) ThisWeek(ctx context.Context, userID pgtype.UUID, tz string, libraryID pgtype.UUID) (WeekSummaryStats, error) {
|
||||
statsRange, err := ResolveStatsRange(tz, "7")
|
||||
if err != nil {
|
||||
return WeekSummaryStats{}, err
|
||||
}
|
||||
loc := utils.UserLocation(tz)
|
||||
|
||||
visibleIDs, err := s.userVisibleLibraryIDs(ctx, userID)
|
||||
if err != nil {
|
||||
return WeekSummaryStats{}, err
|
||||
}
|
||||
|
||||
// Scan the 14-day window: [start - 7 days, end].
|
||||
scanStart := statsRange.Start.AddDate(0, 0, -7)
|
||||
sessions, err := s.db.GetUserReadingSessionsRange(ctx, database.GetUserReadingSessionsRangeParams{
|
||||
UserID: userID,
|
||||
StartTime: pgtype.Timestamptz{Time: scanStart, Valid: true},
|
||||
EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true},
|
||||
LibraryID: libraryID,
|
||||
VisibleLibraryIds: visibleOrNil(visibleIDs),
|
||||
})
|
||||
if err != nil {
|
||||
return WeekSummaryStats{}, fmt.Errorf("failed to load reading sessions: %w", err)
|
||||
}
|
||||
|
||||
week := map[string]bool{statsRange.Start.Format("2006-01-02"): true}
|
||||
for i := 0; i < statsRange.Days; i++ {
|
||||
week[statsRange.Start.AddDate(0, 0, i).Format("2006-01-02")] = true
|
||||
}
|
||||
|
||||
out := WeekSummaryStats{
|
||||
ActiveToday: week[statsRange.End.Format("2006-01-02")],
|
||||
CurrentStreak: 0,
|
||||
}
|
||||
booksThis, booksPrev := make(map[string]bool), make(map[string]bool)
|
||||
|
||||
for _, sess := range sessions {
|
||||
// Same bucket key as UserSummary: the session END instant.
|
||||
ts := sess.CreatedAt
|
||||
if sess.ReadingSessionEnd.Valid {
|
||||
ts = sess.ReadingSessionEnd
|
||||
}
|
||||
key := dayKey(ts, loc)
|
||||
minutes := 0
|
||||
if sess.TimeSpentSeconds.Valid {
|
||||
minutes = int(sess.TimeSpentSeconds.Int32) / 60
|
||||
}
|
||||
pages := 0
|
||||
if sess.PagesRead.Valid {
|
||||
pages = int(sess.PagesRead.Int32)
|
||||
}
|
||||
mediaUUID, err := uuid.FromBytes(sess.MediaItemID.Bytes[0:16])
|
||||
var bookKey string
|
||||
if err == nil {
|
||||
bookKey = mediaUUID.String()
|
||||
}
|
||||
|
||||
if week[key] { // current 7 days
|
||||
out.WeekMinutes += minutes
|
||||
out.WeekPages += pages
|
||||
out.WeekSessions++
|
||||
if bookKey != "" {
|
||||
booksThis[bookKey] = true
|
||||
}
|
||||
} else { // previous 7 days
|
||||
out.PrevMinutes += minutes
|
||||
out.PrevPages += pages
|
||||
out.PrevSessions++
|
||||
if bookKey != "" {
|
||||
booksPrev[bookKey] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
out.WeekBooks = len(booksThis)
|
||||
_ = booksPrev
|
||||
|
||||
activeDays, err := s.db.GetUserReadingActiveDays(ctx, database.GetUserReadingActiveDaysParams{
|
||||
Tz: loc.String(),
|
||||
UserID: userID,
|
||||
StartTime: pgtype.Timestamptz{Time: statsRange.Start.AddDate(0, 0, -(streakLookbackDays - statsRange.Days)), Valid: true},
|
||||
LibraryID: libraryID,
|
||||
VisibleLibraryIds: visibleOrNil(visibleIDs),
|
||||
})
|
||||
if err == nil {
|
||||
out.CurrentStreak = computeStreaks(activeDays, statsRange.End.Format("2006-01-02")).Current
|
||||
out.ActiveToday = computeStreaks(activeDays, statsRange.End.Format("2006-01-02")).ActiveToday
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// BookStats is the per-book card: lifetime totals + the most recent
|
||||
// sessions (bounded so the detail page never scans unbounded history).
|
||||
func (s *StatisticsService) BookStats(ctx context.Context, userID pgtype.UUID, mediaItemID uuid.UUID, tz string) (BookStats, error) {
|
||||
sessions, err := s.db.GetBookReadingSessions(ctx, database.GetBookReadingSessionsParams{
|
||||
UserID: userID,
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return BookStats{}, fmt.Errorf("failed to load book sessions: %w", err)
|
||||
}
|
||||
|
||||
out := BookStats{
|
||||
MediaItemID: mediaItemID.String(),
|
||||
RecentSessions: []SessionSummary{},
|
||||
}
|
||||
for i, sess := range sessions {
|
||||
if sess.TimeSpentSeconds.Valid {
|
||||
out.TotalMinutes += int(sess.TimeSpentSeconds.Int32) / 60
|
||||
}
|
||||
if sess.PagesRead.Valid {
|
||||
out.TotalPages += int(sess.PagesRead.Int32)
|
||||
}
|
||||
out.SessionCount++
|
||||
if sess.ProgressPercentage.Valid && sess.ProgressPercentage.Float64 > out.MaxProgress {
|
||||
out.MaxProgress = sess.ProgressPercentage.Float64
|
||||
}
|
||||
out.Finished = out.Finished || (sess.ProgressPercentage.Valid && sess.ProgressPercentage.Float64 >= finishedThreshold)
|
||||
|
||||
start := ""
|
||||
if sess.ReadingSessionStart.Valid {
|
||||
start = utils.FormatTimeInTimezone(sess.ReadingSessionStart.Time, tz)
|
||||
}
|
||||
end := ""
|
||||
if sess.ReadingSessionEnd.Valid {
|
||||
end = utils.FormatTimeInTimezone(sess.ReadingSessionEnd.Time, tz)
|
||||
}
|
||||
if sess.ReadingSessionEnd.Valid {
|
||||
if i == 0 {
|
||||
out.FirstSession = end
|
||||
}
|
||||
out.LastSession = end // ascending order → last assignment wins
|
||||
}
|
||||
|
||||
if i >= len(sessions)-10 { // the most recent 10 sessions only
|
||||
summary := SessionSummary{
|
||||
StartedAt: start,
|
||||
EndedAt: end,
|
||||
Minutes: int(sess.TimeSpentSeconds.Int32) / 60,
|
||||
Source: metadataSource(sess.DeviceMetadata),
|
||||
}
|
||||
if sess.PagesRead.Valid {
|
||||
summary.Pages = int(sess.PagesRead.Int32)
|
||||
}
|
||||
if sess.ProgressPercentage.Valid {
|
||||
summary.Progress = sess.ProgressPercentage.Float64
|
||||
}
|
||||
out.RecentSessions = append(out.RecentSessions, summary)
|
||||
}
|
||||
}
|
||||
// Most-recent first for display.
|
||||
for i, j := 0, len(out.RecentSessions)-1; i < j; i, j = i+1, j-1 {
|
||||
out.RecentSessions[i], out.RecentSessions[j] = out.RecentSessions[j], out.RecentSessions[i]
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// computeStreaks walks an ascending ISO-date list (the active-day
|
||||
// calendar in the user's timezone) anchored at TODAY's date.
|
||||
func computeStreaks(activeDays []string, today string) StreakStats {
|
||||
activeSet := make(map[string]bool, len(activeDays))
|
||||
for _, d := range activeDays {
|
||||
activeSet[d] = true
|
||||
}
|
||||
|
||||
out := StreakStats{ActiveToday: activeSet[today], Current: 0, Longest: 0}
|
||||
|
||||
// Current: walk back from today; an inactive TODAY falls through to
|
||||
// yesterday so a not-yet-read day doesn't erase a live streak.
|
||||
anchor := today
|
||||
if !activeSet[anchor] {
|
||||
if d, err := time.Parse("2006-01-02", today); err == nil {
|
||||
anchor = d.AddDate(0, 0, -1).Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
for cur := anchor; activeSet[cur]; {
|
||||
out.Current++
|
||||
d, err := time.Parse("2006-01-02", cur)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
cur = d.AddDate(0, 0, -1).Format("2006-01-02")
|
||||
}
|
||||
|
||||
// Longest run in the ascending ordered list.
|
||||
run := 0
|
||||
var prev string
|
||||
for _, d := range activeDays {
|
||||
if prev != "" && nextDay(prev) == d {
|
||||
run++
|
||||
} else {
|
||||
run = 1
|
||||
}
|
||||
if run > out.Longest {
|
||||
out.Longest = run
|
||||
}
|
||||
prev = d
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// nextDay returns the day after an ISO date, preserving the format.
|
||||
func nextDay(iso string) string {
|
||||
d, err := time.Parse("2006-01-02", iso)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return d.AddDate(0, 0, 1).Format("2006-01-02")
|
||||
}
|
||||
|
||||
// dayKey buckets a session instant into the user's calendar date.
|
||||
func dayKey(ts pgtype.Timestamptz, loc *time.Location) string {
|
||||
if !ts.Valid {
|
||||
return ""
|
||||
}
|
||||
return ts.Time.In(loc).Format("2006-01-02")
|
||||
}
|
||||
|
||||
// metadataSource extracts the capture's source tag from device_metadata.
|
||||
func metadataSource(raw []byte) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var meta struct {
|
||||
Source string `json:"source"`
|
||||
}
|
||||
if jsonErr := json.Unmarshal(raw, &meta); jsonErr == nil {
|
||||
return meta.Source
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -258,6 +258,7 @@ func (s *AnnotationService) applyLWW(
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
DeviceSyncData: deviceData,
|
||||
UserID: req.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update highlight: %w", err)
|
||||
@@ -328,14 +329,22 @@ func (s *AnnotationService) TombstoneHighlight(
|
||||
|
||||
func (s *AnnotationService) TombstoneHighlightByID(
|
||||
ctx context.Context,
|
||||
highlightID pgtype.UUID,
|
||||
userID, highlightID pgtype.UUID,
|
||||
source string,
|
||||
) error {
|
||||
h, err := s.db.GetMediaHighlight(ctx, highlightID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get highlight for tombstone: %w", err)
|
||||
}
|
||||
err = s.db.TombstoneMediaHighlightByID(ctx, highlightID)
|
||||
// Ownership predicate: the caller must own the row. A mismatched user
|
||||
// gets a not-found-style error, never the row's existence.
|
||||
if h.UserID != userID {
|
||||
return fmt.Errorf("tombstone highlight by ID: %w", pgx.ErrNoRows)
|
||||
}
|
||||
err = s.db.TombstoneMediaHighlightByID(ctx, database.TombstoneMediaHighlightByIDParams{
|
||||
ID: highlightID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("tombstone highlight by ID: %w", err)
|
||||
}
|
||||
@@ -646,8 +655,11 @@ func (s *AnnotationService) compareIncomingNote(req SaveNoteRequest, existing da
|
||||
return req.ModifiedAt.After(existingMod.Time), true
|
||||
}
|
||||
|
||||
func (s *AnnotationService) TombstoneNoteByID(ctx context.Context, id pgtype.UUID) error {
|
||||
return s.db.TombstoneMediaNoteByID(ctx, id)
|
||||
func (s *AnnotationService) TombstoneNoteByID(ctx context.Context, id, userID pgtype.UUID) error {
|
||||
return s.db.TombstoneMediaNoteByID(ctx, database.TombstoneMediaNoteByIDParams{
|
||||
ID: id,
|
||||
UserID: userID,
|
||||
})
|
||||
}
|
||||
|
||||
type SaveBookmarkRequest struct {
|
||||
@@ -788,6 +800,7 @@ func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookma
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
DeviceSyncData: deviceData,
|
||||
UserID: req.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update bookmark: %w", err)
|
||||
@@ -817,12 +830,19 @@ func (s *AnnotationService) compareIncomingBookmark(req SaveBookmarkRequest, exi
|
||||
return req.ModifiedAt.After(existingMod.Time), true
|
||||
}
|
||||
|
||||
func (s *AnnotationService) TombstoneBookmarkByID(ctx context.Context, bookmarkID pgtype.UUID, source string) error {
|
||||
func (s *AnnotationService) TombstoneBookmarkByID(ctx context.Context, userID, bookmarkID pgtype.UUID, source string) error {
|
||||
bm, err := s.db.GetMediaBookmark(ctx, bookmarkID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get bookmark for tombstone: %w", err)
|
||||
}
|
||||
err = s.db.TombstoneMediaBookmarkByID(ctx, bookmarkID)
|
||||
// Ownership predicate: the caller must own the row.
|
||||
if bm.UserID != userID {
|
||||
return fmt.Errorf("tombstone bookmark by ID: %w", pgx.ErrNoRows)
|
||||
}
|
||||
err = s.db.TombstoneMediaBookmarkByID(ctx, database.TombstoneMediaBookmarkByIDParams{
|
||||
ID: bookmarkID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("tombstone bookmark by ID: %w", err)
|
||||
}
|
||||
|
||||
@@ -626,7 +626,12 @@ func flattenDocumentForSearch(body *html.Node) ([]rune, []docRunePos) {
|
||||
lastBlock = block
|
||||
off := 0
|
||||
for _, r := range n.Data {
|
||||
if unicode.IsSpace(r) {
|
||||
// Zero-width format characters are line-break opportunities,
|
||||
// not word characters: devices render them as breaks and
|
||||
// capture plain spaces, so they must not fuse words in the
|
||||
// search stream. ZWJ (U+200D) is exempt — it glues emoji
|
||||
// sequences.
|
||||
if unicode.IsSpace(r) || r == '\u200b' || r == '\u200c' || r == '\ufeff' {
|
||||
pendingSpace = true
|
||||
} else {
|
||||
if pendingSpace && len(runes) > 0 {
|
||||
@@ -644,9 +649,12 @@ func flattenDocumentForSearch(body *html.Node) ([]rune, []docRunePos) {
|
||||
var walk func(n *html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.TextNode {
|
||||
if strings.TrimSpace(n.Data) != "" {
|
||||
// Whitespace-only nodes still participate: a space carried in
|
||||
// its own inline span (<span class="sp"> </span>) is a real
|
||||
// word separator, and skipping it fuses adjacent words in the
|
||||
// search stream. appendNode turns such nodes into pending
|
||||
// space without emitting anything.
|
||||
appendNode(n)
|
||||
}
|
||||
return
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package sync
|
||||
|
||||
// Text-anchored range conversion for device highlights.
|
||||
//
|
||||
// Why this exists: CRE xpointers address text as text()[N].offset, but
|
||||
// crengine's text-node enumeration does not survive to the raw XHTML on
|
||||
// disk (whitespace runs, entity merges, live-DOM splits), and the trailing
|
||||
// offset alone lands at a global block offset that is wrong for any anchor
|
||||
// past the first text node of a block. Converting pos0 and pos1
|
||||
// independently made it worse: mismatched start/end base paths and
|
||||
// backwards ranges — highlights that store but never paint (or paint in
|
||||
// the wrong place) in the web reader.
|
||||
//
|
||||
// The device's selection text, however, is a quote of the document. So for
|
||||
// highlights we anchor BOTH endpoints by locating that quote in the
|
||||
// flattened document text (whitespace-normalized, crossing block
|
||||
// boundaries), and use the device locators only as disambiguation hints
|
||||
// when the quote occurs more than once. Endpoints derived from one match
|
||||
// are same-spine and ordered by construction — the two properties the
|
||||
// reader's range-CFI builder needs.
|
||||
|
||||
import (
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// ConvertHighlightRange resolves a highlight's pos0/pos1 pair into a start
|
||||
// CFI plus a true range end by locating the selection text in the document.
|
||||
// It returns nil when the text cannot be located; callers fall back to the
|
||||
// legacy per-endpoint conversion.
|
||||
func (c *CFIConverter) ConvertHighlightRange(pos0, pos1, contextText string, storedPercentage float64) *ConversionResult {
|
||||
if !IsCREXPointer(pos0) {
|
||||
return nil
|
||||
}
|
||||
xp, err := ParseCREXPointer(pos0)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
doc, href, err := c.getContentDoc(xp.FragmentIndex)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
body := findBody(doc)
|
||||
if body == nil {
|
||||
return nil
|
||||
}
|
||||
spineIndex := xp.FragmentIndex - 1
|
||||
|
||||
// KOReader wraps partial-paragraph captures in curly braces; they are
|
||||
// wire markers, not document text. Zero-width format characters in the
|
||||
// capture are break opportunities the device rendered — normalize to
|
||||
// spaces so both sides of the match agree.
|
||||
ctx := normalizeWhitespace(strings.Trim(contextText, "{}"))
|
||||
ctx = strings.NewReplacer("\u200b", " ", "\u200c", " ", "\ufeff", " ").Replace(ctx)
|
||||
ctx = normalizeWhitespace(ctx)
|
||||
if ctx == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
runes, poss := flattenDocumentForSearch(body)
|
||||
text := string(runes)
|
||||
|
||||
words := strings.Fields(ctx)
|
||||
quoted := make([]string, len(words))
|
||||
for i, w := range words {
|
||||
quoted[i] = regexp.QuoteMeta(w)
|
||||
}
|
||||
re, err := regexp.Compile(strings.Join(quoted, `\s+`))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
matches := re.FindAllStringIndex(text, -1)
|
||||
if len(matches) == 0 {
|
||||
log.Printf("Bookhoard: highlight range: selection %q not found in %s", truncateForLog(ctx, 60), href)
|
||||
return nil
|
||||
}
|
||||
loc := pickHighlightMatch(matches, text, poss, xp, pos1, body)
|
||||
if len(matches) > 1 {
|
||||
log.Printf("Bookhoard: highlight range: %d matches for %q in %s, resolved by device hint", len(matches), truncateForLog(ctx, 40), href)
|
||||
}
|
||||
|
||||
startRune := utf8.RuneCountInString(text[:loc[0]])
|
||||
endRune := utf8.RuneCountInString(text[:loc[1]]) // exclusive
|
||||
|
||||
startPos := poss[startRune]
|
||||
startCFI, err := buildCFI(spineIndex, startPos.node, startPos.off)
|
||||
if err != nil || startCFI == "" {
|
||||
log.Printf("Bookhoard: highlight range: buildCFI(start) failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
endCFI := ""
|
||||
if endRune > startRune && endRune <= len(poss) {
|
||||
endPos := poss[endRune-1]
|
||||
endOff := endPos.off + 1
|
||||
if n := utf8.RuneCountInString(endPos.node.Data); endOff > n {
|
||||
endOff = n
|
||||
}
|
||||
if ec, err := buildCFI(spineIndex, endPos.node, endOff); err == nil && ec != "" {
|
||||
endCFI = ec
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Bookhoard: highlight range %q → %s (precision: exact)", truncateForLog(ctx, 60), startCFI)
|
||||
return &ConversionResult{
|
||||
EPUBCFI: startCFI,
|
||||
EndEPUBCFI: endCFI,
|
||||
Href: href,
|
||||
Percentage: storedPercentage,
|
||||
Precision: "exact",
|
||||
}
|
||||
}
|
||||
|
||||
// pickHighlightMatch chooses among multiple occurrences of the selection.
|
||||
// pos1's element path (the end anchor's landing block) is the strongest
|
||||
// hint: the true occurrence is the one whose extent stays inside that
|
||||
// block. Without a usable hint, document order wins (first occurrence).
|
||||
func pickHighlightMatch(matches [][]int, text string, poss []docRunePos, xp *CREXPointer, pos1 string, body *html.Node) []int {
|
||||
if len(matches) <= 1 {
|
||||
return matches[0]
|
||||
}
|
||||
if xp1, err := ParseCREXPointer(pos1); err == nil && len(xp1.ElementPath) > 0 {
|
||||
if elem := resolveCREElementPath(body, xp1.ElementPath); elem != nil {
|
||||
lo, hi := elementFlatRange(poss, elem)
|
||||
if lo >= 0 {
|
||||
for _, m := range matches {
|
||||
s := utf8.RuneCountInString(text[:m[0]])
|
||||
e := utf8.RuneCountInString(text[:m[1]]) - 1
|
||||
if s >= lo && e < hi {
|
||||
return m
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches[0]
|
||||
}
|
||||
|
||||
// elementFlatRange returns the [lo, hi) rune range of the flattened stream
|
||||
// covered by elem's subtree, or (-1, 0) when elem contributes no text.
|
||||
func elementFlatRange(poss []docRunePos, elem *html.Node) (int, int) {
|
||||
lo, hi := -1, -1
|
||||
for i, p := range poss {
|
||||
if isDescendantNode(p.node, elem) {
|
||||
if lo < 0 {
|
||||
lo = i
|
||||
}
|
||||
hi = i + 1
|
||||
}
|
||||
}
|
||||
if lo < 0 {
|
||||
return -1, 0
|
||||
}
|
||||
return lo, hi
|
||||
}
|
||||
|
||||
func isDescendantNode(n, ancestor *html.Node) bool {
|
||||
for p := n.Parent; p != nil; p = p.Parent {
|
||||
if p == ancestor {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package sync
|
||||
|
||||
// Manual repro harness for the CRE→CFI highlight conversion defects found in
|
||||
// "Clean" (Rosser K Powitzky): 28 device highlights whose stored web CFIs are
|
||||
// structurally inconsistent (mismatched start/end base paths, backwards
|
||||
// ranges). This test mirrors handlers.convertHighlightPositions exactly —
|
||||
// pos0 converted WITH the selection text as context, pos1 WITHOUT any — then
|
||||
// reports the result alongside the CFIs stored at ingest time and a
|
||||
// ground-truth oracle computed by locating the selection text in the document
|
||||
// itself. Skips silently when the book/export are not on this machine, like
|
||||
// cfi_verify_manual_test.go.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
const (
|
||||
reproExport = "/home/nymusicman/Code/bookhoard/export/media_highlights.json"
|
||||
reproEpub = "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Rosser K Powitzky/Clean_ How the Jewish Roots of Atone (77)/Clean_ How the Jewish Roots of - Rosser K Powitzky.epub"
|
||||
)
|
||||
|
||||
type reproRow struct {
|
||||
ID string `json:"id"`
|
||||
StartPosition string `json:"start_position"`
|
||||
EndPosition string `json:"end_position"`
|
||||
StoredStart string `json:"epubcfi_start"`
|
||||
StoredEnd string `json:"epubcfi_end"`
|
||||
SelectionText string `json:"selection_text"`
|
||||
PctStart float64 `json:"percentage_start"`
|
||||
}
|
||||
|
||||
// webUsable mirrors handlers.webUsableCFI (unexported there; cannot import
|
||||
// the handlers package from here without a cycle).
|
||||
func webUsable(precision, cfi string) string {
|
||||
if precision != "structural" && precision != "exact" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(cfi, "epubcfi(") || !strings.HasSuffix(cfi, ")") {
|
||||
return ""
|
||||
}
|
||||
return cfi
|
||||
}
|
||||
|
||||
// reproExtendCFIByLength mirrors handlers.extendCFIByLength: advance the
|
||||
// trailing character offset of a point CFI by the UTF-16 length of text.
|
||||
func reproExtendCFIByLength(cfi, text string) string {
|
||||
if cfi == "" || text == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasSuffix(cfi, ")") {
|
||||
return ""
|
||||
}
|
||||
inner := cfi[:len(cfi)-1]
|
||||
idx := strings.LastIndex(inner, ":")
|
||||
if idx < 0 {
|
||||
return cfi
|
||||
}
|
||||
var off int
|
||||
if _, err := fmtSscanInt(inner[idx+1:], &off); err != nil {
|
||||
return cfi
|
||||
}
|
||||
return inner[:idx+1] + itoa(off+utf16Len(text)) + ")"
|
||||
}
|
||||
|
||||
// cfiBase splits "epubcfi(...)" into (base, offset): base is everything
|
||||
// before the final ":N" character-offset terminator.
|
||||
func cfiBase(cfi string) (string, int, bool) {
|
||||
if !strings.HasSuffix(cfi, ")") {
|
||||
return "", 0, false
|
||||
}
|
||||
inner := cfi[:len(cfi)-1]
|
||||
idx := strings.LastIndex(inner, ":")
|
||||
if idx < 0 {
|
||||
return inner, 0, true
|
||||
}
|
||||
off := 0
|
||||
if _, err := fmtSscanInt(inner[idx+1:], &off); err != nil {
|
||||
return inner, 0, true
|
||||
}
|
||||
return inner[:idx], off, true
|
||||
}
|
||||
|
||||
func fmtSscanInt(s string, out *int) (int, error) {
|
||||
n := 0
|
||||
ok := false
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
break
|
||||
}
|
||||
n = n*10 + int(r-'0')
|
||||
ok = true
|
||||
}
|
||||
if !ok {
|
||||
return 0, errReproNoInt
|
||||
}
|
||||
*out = n
|
||||
return len(s), nil
|
||||
}
|
||||
|
||||
var errReproNoInt = errReproNoIntErr{}
|
||||
|
||||
type errReproNoIntErr struct{}
|
||||
|
||||
func (errReproNoIntErr) Error() string { return "no integer" }
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var b [20]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
b[i] = '-'
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
|
||||
// oracleCFIs locates the selection text in the document named by the start
|
||||
// xpointer's fragment and returns ground-truth (startCFI, endCFI, spineIndex).
|
||||
// Whitespace runs are collapsed on both sides before matching, so device
|
||||
// text with reflowed newlines still lands.
|
||||
func oracleCFIs(t *testing.T, conv *CFIConverter, row reproRow) (string, string, string) {
|
||||
xp, err := ParseCREXPointer(row.StartPosition)
|
||||
if err != nil {
|
||||
return "", "", "unparseable start xpointer"
|
||||
}
|
||||
doc, href, err := conv.getContentDoc(xp.FragmentIndex)
|
||||
if err != nil {
|
||||
return "", "", "content doc missing: " + err.Error()
|
||||
}
|
||||
body := findBody(doc)
|
||||
if body == nil {
|
||||
return "", "", "no body"
|
||||
}
|
||||
|
||||
// Flatten every text node in document order.
|
||||
var segs []textSegment
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.TextNode {
|
||||
segs = append(segs, textSegment{node: n, runes: []rune(n.Data)})
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(body)
|
||||
|
||||
var flat []rune
|
||||
for _, s := range segs {
|
||||
flat = append(flat, s.runes...)
|
||||
}
|
||||
|
||||
needle := []rune(normalizeWhitespace(row.SelectionText))
|
||||
if len(needle) == 0 {
|
||||
return "", "", "empty selection text"
|
||||
}
|
||||
// Collapse whitespace in `flat` while mapping each kept rune back to a
|
||||
// flat index.
|
||||
var norm []rune
|
||||
var normToFlat []int
|
||||
lastWS := false
|
||||
for i, r := range flat {
|
||||
isWS := r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == ' ' || r == ''
|
||||
if isWS {
|
||||
if lastWS {
|
||||
continue
|
||||
}
|
||||
norm = append(norm, ' ')
|
||||
normToFlat = append(normToFlat, i)
|
||||
lastWS = true
|
||||
continue
|
||||
}
|
||||
norm = append(norm, r)
|
||||
normToFlat = append(normToFlat, i)
|
||||
lastWS = false
|
||||
}
|
||||
|
||||
// Trim wrapped-quote artifacts KOReader adds to partial-paragraph text.
|
||||
trimmed := needle
|
||||
for len(trimmed) > 0 && (trimmed[0] == '{' || trimmed[0] == '}') {
|
||||
trimmed = trimmed[1:]
|
||||
}
|
||||
for len(trimmed) > 0 && (trimmed[len(trimmed)-1] == '{' || trimmed[len(trimmed)-1] == '}') {
|
||||
trimmed = trimmed[:len(trimmed)-1]
|
||||
}
|
||||
|
||||
pos := indexRunes(norm, trimmed)
|
||||
if pos < 0 {
|
||||
return "", "", "selection text not found in doc " + href
|
||||
}
|
||||
|
||||
flatStart := normToFlat[pos]
|
||||
flatEnd := normToFlat[pos+len(trimmed)-1] + 1 // end is exclusive
|
||||
|
||||
nodeAt := func(flatIdx int) (*html.Node, int) {
|
||||
acc := 0
|
||||
for _, s := range segs {
|
||||
n := len(s.runes)
|
||||
if flatIdx < acc+n {
|
||||
return s.node, flatIdx - acc
|
||||
}
|
||||
acc += n
|
||||
}
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
startNode, startOff := nodeAt(flatStart)
|
||||
endNode, endOff := nodeAt(flatEnd)
|
||||
if startNode == nil || endNode == nil {
|
||||
return "", "", "flat index out of range"
|
||||
}
|
||||
|
||||
spineIndex := xp.FragmentIndex - 1
|
||||
startCFI, err := buildCFI(spineIndex, startNode, startOff)
|
||||
if err != nil {
|
||||
return "", "", "buildCFI start: " + err.Error()
|
||||
}
|
||||
endCFI, err := buildCFI(spineIndex, endNode, endOff)
|
||||
if err != nil {
|
||||
return "", "", "buildCFI end: " + err.Error()
|
||||
}
|
||||
return startCFI, endCFI, ""
|
||||
}
|
||||
|
||||
func indexRunes(hay, needle []rune) int {
|
||||
if len(needle) == 0 || len(needle) > len(hay) {
|
||||
return -1
|
||||
}
|
||||
for i := 0; i+len(needle) <= len(hay); i++ {
|
||||
match := true
|
||||
for j := range needle {
|
||||
if hay[i+j] != needle[j] {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func TestManualCleanRepro(t *testing.T) {
|
||||
raw, err := os.ReadFile(reproExport)
|
||||
if err != nil {
|
||||
t.Skipf("export json not present: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(reproEpub); err != nil {
|
||||
t.Skipf("book epub not present: %v", err)
|
||||
}
|
||||
var rows []reproRow
|
||||
if err := json.Unmarshal(raw, &rows); err != nil {
|
||||
t.Fatalf("unmarshal export: %v", err)
|
||||
}
|
||||
|
||||
conv := cachedConverter(reproEpub)
|
||||
|
||||
stats := map[string]int{}
|
||||
for _, row := range rows {
|
||||
// Mirror handlers.convertHighlightPositions (text-anchored primary
|
||||
// path, legacy per-endpoint fallback).
|
||||
startLoc := ConvertHighlightRangeToCanonical(reproEpub, "reflowable", row.StartPosition, row.EndPosition, row.SelectionText, row.PctStart)
|
||||
startCFI := webUsable(startLoc.Precision, startLoc.CFI)
|
||||
endCFI := ""
|
||||
if startCFI != "" && startLoc.EndCFI != "" {
|
||||
endCFI = webUsable(startLoc.Precision, startLoc.EndCFI)
|
||||
}
|
||||
if startCFI == "" {
|
||||
startLoc = ConvertToCanonical(LocatorSourceKOReader, row.StartPosition, row.PctStart, row.SelectionText, "reflowable", reproEpub, "")
|
||||
startCFI = webUsable(startLoc.Precision, startLoc.CFI)
|
||||
endCFI = ""
|
||||
if startCFI != "" && startLoc.EndCFI != "" {
|
||||
endCFI = startLoc.EndCFI
|
||||
}
|
||||
if endCFI == "" && row.EndPosition != "" {
|
||||
endLoc := ConvertToCanonical(LocatorSourceKOReader, row.EndPosition, row.PctStart, "", "reflowable", reproEpub, "")
|
||||
endCFI = webUsable(endLoc.Precision, endLoc.CFI)
|
||||
}
|
||||
if endCFI == "" && startCFI != "" && row.SelectionText != "" {
|
||||
endCFI = reproExtendCFIByLength(startCFI, row.SelectionText)
|
||||
}
|
||||
}
|
||||
|
||||
oracleStart, oracleEnd, oracleNote := oracleCFIs(t, conv, row)
|
||||
stats["oracleMiss:"+statusOr(oracleNote)]++
|
||||
|
||||
cls := func(a, b string) string {
|
||||
ba, oa, okA := cfiBase(a)
|
||||
bb, ob, okB := cfiBase(b)
|
||||
switch {
|
||||
case !okA || !okB || a == "" || b == "":
|
||||
return "missing"
|
||||
case ba != bb:
|
||||
return "diff-base"
|
||||
case ob <= oa:
|
||||
return "backwards"
|
||||
default:
|
||||
return "healthy"
|
||||
}
|
||||
}
|
||||
cur := cls(startCFI, endCFI)
|
||||
sto := cls(row.StoredStart, row.StoredEnd)
|
||||
ora := cls(oracleStart, oracleEnd)
|
||||
stats["current:"+cur]++
|
||||
stats["stored:"+sto]++
|
||||
stats["oracle:"+ora]++
|
||||
|
||||
if os.Getenv("REPRO_VERBOSE") != "" || cur != "healthy" || sto != "healthy" {
|
||||
t.Logf("row %s", row.ID[:8])
|
||||
t.Logf(" raw: %s -> %s", row.StartPosition, row.EndPosition)
|
||||
t.Logf(" ctx: %q", truncateRunes(row.SelectionText, 60))
|
||||
t.Logf(" stored: %-38s %-38s [%s]", row.StoredStart, row.StoredEnd, sto)
|
||||
t.Logf(" current: %-38s %-38s [%s]", startCFI, endCFI, cur)
|
||||
t.Logf(" oracle: %-38s %-38s [%s] %s", oracleStart, oracleEnd, ora, oracleNote)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("summary: %v", stats)
|
||||
|
||||
// REPRO_EMIT_SQL=<path>: emit UPDATE statements carrying the NEW
|
||||
// converter output into the database rows this export came from
|
||||
// (one-off healing of rows written by the broken converter).
|
||||
if out := os.Getenv("REPRO_EMIT_SQL"); out != "" {
|
||||
var b strings.Builder
|
||||
b.WriteString("BEGIN;\n")
|
||||
for _, row := range rows {
|
||||
startLoc := ConvertHighlightRangeToCanonical(reproEpub, "reflowable", row.StartPosition, row.EndPosition, row.SelectionText, row.PctStart)
|
||||
startCFI := webUsable(startLoc.Precision, startLoc.CFI)
|
||||
endCFI := ""
|
||||
if startCFI != "" && startLoc.EndCFI != "" {
|
||||
endCFI = webUsable(startLoc.Precision, startLoc.EndCFI)
|
||||
}
|
||||
if startCFI == "" {
|
||||
continue // leave untouched; legacy fallback rows keep old CFIs
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(
|
||||
"UPDATE media_highlights SET epubcfi_start=%s, epubcfi_end=%s WHERE id='%s';\n",
|
||||
pgQuote(startCFI), pgQuote(endCFI), row.ID,
|
||||
))
|
||||
}
|
||||
b.WriteString("COMMIT;\n")
|
||||
if err := os.WriteFile(out, []byte(b.String()), 0o600); err != nil {
|
||||
t.Fatalf("write sql: %v", err)
|
||||
}
|
||||
t.Logf("wrote %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func pgQuote(s string) string {
|
||||
if s == "" {
|
||||
return "''"
|
||||
}
|
||||
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
||||
}
|
||||
|
||||
func statusOr(s string) string {
|
||||
if s == "" {
|
||||
return "found"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -125,6 +125,38 @@ func ConvertToCanonical(
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertHighlightRangeToCanonical is the highlight-specific twin of
|
||||
// ConvertToCanonical: it anchors a device highlight's pos0/pos1 pair by the
|
||||
// selection text so both endpoints derive from one document match — same
|
||||
// spine, ordered — instead of two independent structural landings. Returns
|
||||
// a "fallback" locator with an empty CFI when the text cannot be located;
|
||||
// callers fall back to ConvertToCanonical per endpoint.
|
||||
func ConvertHighlightRangeToCanonical(
|
||||
epubPath string,
|
||||
formatGroup string,
|
||||
pos0 string,
|
||||
pos1 string,
|
||||
contextText string,
|
||||
percentage float64,
|
||||
) CanonicalLocator {
|
||||
if !isConvertible(formatGroup) || epubPath == "" {
|
||||
return CanonicalLocator{CFI: pos0, Precision: "passthrough", Percentage: percentage}
|
||||
}
|
||||
if !IsCREXPointer(pos0) {
|
||||
return CanonicalLocator{CFI: pos0, Precision: "already-standard", Percentage: percentage}
|
||||
}
|
||||
result := cachedConverter(epubPath).ConvertHighlightRange(pos0, pos1, contextText, percentage)
|
||||
if result == nil || result.EPUBCFI == "" {
|
||||
return CanonicalLocator{CFI: "", Precision: "fallback", Percentage: percentage}
|
||||
}
|
||||
return CanonicalLocator{
|
||||
CFI: result.EPUBCFI,
|
||||
EndCFI: result.EndEPUBCFI,
|
||||
Precision: result.Precision,
|
||||
Percentage: result.Percentage,
|
||||
}
|
||||
}
|
||||
|
||||
func ConvertFromCanonical(
|
||||
source LocatorSource,
|
||||
canonicalCFI string,
|
||||
|
||||
@@ -315,6 +315,16 @@ type SaveProgressRequest struct {
|
||||
DeviceType string
|
||||
DeviceName string
|
||||
Broadcast bool
|
||||
|
||||
// ClearLocator declares that this write INTENDS to carry no position
|
||||
// locator: percentage-only writers (devices whose converter declined,
|
||||
// Kobo markup percentage syncs, queue entries without CFI). Without it,
|
||||
// seeding below would re-pair the new percentage with the PREVIOUS
|
||||
// writer's stale CFI/context — the incoherent row shape that feeds the
|
||||
// client-side far-anchor restore floor. Locater-less rows are honest:
|
||||
// percentage restores are approximate, and the next anchor-bearing
|
||||
// write re-establishes the pair. Request-supplied fields always win.
|
||||
ClearLocator bool
|
||||
}
|
||||
|
||||
func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequest) (database.ReadingProgress, error) {
|
||||
@@ -407,6 +417,22 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
|
||||
params.ReadingMode = pgtype.Text{String: *req.ReadingMode, Valid: true}
|
||||
}
|
||||
|
||||
// Locator coherence: a write that declares no locator must not
|
||||
// silently re-pair its new percentage with the previous writer's
|
||||
// stale CFI/context (inherited by the seeding above). Clear them so
|
||||
// the row is an honest percentage-only position. Request-carried
|
||||
// fields always win — and a request-carried context survives so the
|
||||
// verify/heal pass below can mint a fresh anchor from it.
|
||||
if req.ClearLocator && req.Epubcfi == nil {
|
||||
params.Epubcfi = pgtype.Text{}
|
||||
if req.ContextText == nil {
|
||||
params.ContextText = pgtype.Text{}
|
||||
}
|
||||
if req.CharacterOffset == nil {
|
||||
params.CharacterOffset = pgtype.Int8{}
|
||||
}
|
||||
}
|
||||
|
||||
params.LastSyncDevice = pgtype.Text{String: req.Source, Valid: true}
|
||||
params.LastSyncSource = pgtype.Text{String: req.Source, Valid: true}
|
||||
|
||||
@@ -571,9 +597,96 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
|
||||
)
|
||||
}
|
||||
|
||||
// Reading-history capture: threading stats off the sync stream. Every
|
||||
// successful progress write yields one reading_history session row —
|
||||
// the span since the PREVIOUS write instant (bounds are one or the
|
||||
// other: the row is recorded regardless, time attribution is
|
||||
// conservative). Client cadence drives the granularity (web/app
|
||||
// debounced pushes, KOReader page-count pushes); nothing second-level
|
||||
// is promised and nothing client-side changes.
|
||||
s.captureReadingSession(ctx, req, existing, hasExisting, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// maxAttributedSessionSeconds caps a derived session: pushes farther
|
||||
// apart than this carry unattributable idle time (overnight suspend →
|
||||
// resume, offline queue flushes) and are kept as zero-second markers
|
||||
// instead of inflating totals.
|
||||
const maxAttributedSessionSeconds = int32(2 * 60 * 60)
|
||||
|
||||
// captureReadingSession records one reading_history row for this push.
|
||||
// Never fails the sync: capture errors are logged and swallowed.
|
||||
func (s *ProgressService) captureReadingSession(ctx context.Context, req SaveProgressRequest, existing database.ReadingProgress, hasExisting bool, saved database.ReadingProgress) {
|
||||
var seconds int32
|
||||
sessionEnd := saved.LastReadAt
|
||||
sessionStart := sessionEnd // first-contact writes start at the push itself
|
||||
if sessionEnd.Valid && hasExisting && existing.LastReadAt.Valid {
|
||||
sessionStart = existing.LastReadAt
|
||||
if elapsed := int32(sessionEnd.Time.Sub(existing.LastReadAt.Time).Seconds()); elapsed > 0 {
|
||||
if elapsed <= maxAttributedSessionSeconds {
|
||||
seconds = elapsed // verified-continuous span
|
||||
} // else: idle gap — marker row, zero time
|
||||
}
|
||||
}
|
||||
|
||||
// Pages read: only when both sides carry a real page index (0 means
|
||||
// unknown for several clients, e.g. LOC-less KOReader pushes).
|
||||
var pages pgtype.Int4
|
||||
if hasExisting && existing.CurrentPage.Valid && existing.CurrentPage.Int32 > 0 &&
|
||||
saved.CurrentPage.Valid && saved.CurrentPage.Int32 > 0 {
|
||||
if deltaPages := saved.CurrentPage.Int32 - existing.CurrentPage.Int32; deltaPages > 0 && deltaPages <= 5000 {
|
||||
pages = pgtype.Int4{Int32: deltaPages, Valid: true}
|
||||
}
|
||||
}
|
||||
|
||||
// device_id references real devices (FK). The web/app progress paths
|
||||
// historically stuff the USER id into DeviceID (it only fed a
|
||||
// broadcast label before stats existed) — probe before writing,
|
||||
// otherwise capture a device-less session and keep the label in
|
||||
// metadata only.
|
||||
var deviceID pgtype.UUID
|
||||
if req.DeviceID.Valid {
|
||||
exists, probeErr := s.db.DeviceExistsForUser(ctx, database.DeviceExistsForUserParams{
|
||||
DeviceID: req.DeviceID,
|
||||
UserID: req.UserID,
|
||||
})
|
||||
if probeErr == nil && exists {
|
||||
deviceID = req.DeviceID
|
||||
}
|
||||
}
|
||||
metadata := map[string]string{"source": req.Source}
|
||||
|
||||
_, err := s.db.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{
|
||||
UserID: req.UserID,
|
||||
MediaItemID: req.MediaItemID,
|
||||
DeviceID: deviceID,
|
||||
ProgressPercentage: saved.Percentage,
|
||||
ReadingSessionStart: sessionStart,
|
||||
ReadingSessionEnd: sessionEnd,
|
||||
PagesRead: pages,
|
||||
TimeSpentSeconds: pgtype.Int4{Int32: seconds, Valid: true},
|
||||
DeviceMetadata: mustJSON(metadata),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("reading-history capture failed (non-fatal) for %s: %v", uniqueKeyString(req), err)
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueKeyString(req SaveProgressRequest) string {
|
||||
return uuid.UUID(req.MediaItemID.Bytes).String()
|
||||
}
|
||||
|
||||
// mustJSON marshals metadata or an empty document on failure — capture
|
||||
// must never break on a serialization corner.
|
||||
func mustJSON(v interface{}) []byte {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return []byte(`{}`)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func buildProgressSnapshot(req SaveProgressRequest) map[string]interface{} {
|
||||
data := map[string]interface{}{}
|
||||
if req.Percentage != nil {
|
||||
|
||||
@@ -443,6 +443,10 @@ func (p *SyncQueueProcessor) syncProgress(ctx context.Context, userID pgtype.UUI
|
||||
if v, ok := syncData["epubcfi"].(string); ok {
|
||||
req.Epubcfi = &v
|
||||
}
|
||||
// Entries without a CFI are percentage-only by intent: declare it
|
||||
// so the service clears any inherited stale anchor instead of
|
||||
// re-pairing it with this percentage.
|
||||
req.ClearLocator = req.Epubcfi == nil
|
||||
if v, ok := syncData["context_text"].(string); ok {
|
||||
req.ContextText = &v
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// UserLocation resolves an IANA timezone name to a *time.Location, falling
|
||||
// back to UTC for empty or invalid values. The single resolution point for
|
||||
// server-side display conversion: the server owns timezone rendering, so
|
||||
// clients receive display-ready strings (or tz-aware instants) and never
|
||||
// convert themselves.
|
||||
func UserLocation(timezone string) *time.Location {
|
||||
if timezone == "" {
|
||||
return time.UTC
|
||||
}
|
||||
loc, err := time.LoadLocation(timezone)
|
||||
if err != nil || loc == nil {
|
||||
return time.UTC
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
// FormatTimeInTimezone renders t in the given IANA timezone as
|
||||
// MM-DD-YYYY HH:MM AM/PM (the established display format across the
|
||||
// templates). Zero times render empty.
|
||||
func FormatTimeInTimezone(t time.Time, timezone string) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.In(UserLocation(timezone)).Format("01-02-2006 03:04 PM")
|
||||
}
|
||||
+10
-2
@@ -12,16 +12,24 @@
|
||||
"dev": "npm run build:ts:dev && npm run build:css"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#b7597d6",
|
||||
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#7796b05",
|
||||
"alpinejs": "^3.15.8",
|
||||
"chart.js": "^4.5.1",
|
||||
"dompurify": "^3.4.16",
|
||||
"fflate": "^0.8.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"htmx.org": "^2.0.8",
|
||||
"lunr": "^2.3.9"
|
||||
"lunr": "^2.3.9",
|
||||
"markdown-it": "^15.0.2",
|
||||
"markdown-it-footnote": "^4.0.0",
|
||||
"turndown": "^7.2.4",
|
||||
"turndown-plugin-gfm": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/markdown-it": "^14.2.0",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a synthetic 'torture test' EPUB exercising every CRE-xpointer -> EPUB-CFI
|
||||
conversion hazard the server converter can encounter.
|
||||
|
||||
Each chapter targets one hazard class; constructs carry comments naming the
|
||||
hazard. Output: uploads/Ebooks/Bookhoard Test/CRE CFI Torture Test/CRE CFI Torture Test.epub
|
||||
(or argv[1]). Deterministic output for reproducible tests.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
MIMETYPE = "application/epub+zip"
|
||||
|
||||
CONTAINER = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>
|
||||
"""
|
||||
|
||||
def page(name, title, body):
|
||||
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
||||
<head><title>{title}</title><link rel="stylesheet" type="text/css" href="../css/style.css"/></head>
|
||||
<body>
|
||||
<!-- chapter: {name} -->
|
||||
<h1 id="{name}-h">{title}</h1>
|
||||
{body}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
CHAPTERS = []
|
||||
|
||||
# 1. The core bug shape: multi-text-node paragraphs (inline markup splitting text)
|
||||
CHAPTERS.append(("split-text", "Split Text Nodes", """
|
||||
<p id="st-p1">In the beginning <span class="verse">1</span> God created the heaven and the earth <span class="verse">2</span> and the earth was without form.</p>
|
||||
<p id="st-p2">A <em>single</em> sentence with <strong>several</strong> inline <span class="mark">marked</span> spans inside it.</p>
|
||||
<p id="st-p3">First text node here.<span class="cont">Immediately-following span text.</span>Text that continues after the span.<span class="cont2">And a second span.</span>Tail text node.</p>
|
||||
<p id="st-p4">Quoted "double" and 'single' marks, an ampersand & entity, and a unicode em—dash.</p>
|
||||
<div class="group" id="st-group">
|
||||
<p id="st-p5">Nested group paragraph one <span class="verse">1</span> with more text after the marker.</p>
|
||||
<p id="st-p6">Nested group paragraph two, plain text only.</p>
|
||||
</div>
|
||||
"""))
|
||||
|
||||
# 2. Whitespace-only text nodes + comments between elements (sibling-index drift)
|
||||
CHAPTERS.append(("whitespace-comments", "Whitespace and Comments", """
|
||||
<ul id="wc-list">
|
||||
<!-- comment directly inside list -->
|
||||
<li id="wc-li1">
|
||||
<p id="wc-p1">Indented markup with whitespace text nodes on every side.</p>
|
||||
<!-- comment between siblings -->
|
||||
<p id="wc-p2">Second indented paragraph with irregular internal spacing.</p>
|
||||
</li>
|
||||
<!-- another comment -->
|
||||
<li id="wc-li2">
|
||||
<p id="wc-p3">Paragraph after multiple comment and whitespace siblings.</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p id="wc-p4">Tail paragraph after the list.</p>
|
||||
"""))
|
||||
|
||||
# 3. Drop caps + footnote anchors (single-char inline splits)
|
||||
CHAPTERS.append(("dropcaps-footnotes", "Dropcaps and Footnotes", """
|
||||
<p id="df-p1"><span class="dropcap">O</span>nce upon a time a drop cap split the first word from itself.</p>
|
||||
<p id="df-p2">A claim requiring support<span class="fnref"><a id="fnref-1" href="#fn-1">1</a></span> and a second claim<span class="fnref"><a id="fnref-2" href="#fn-2">2</a></span> in the same paragraph.</p>
|
||||
<p id="df-p3">Multiple <a href="#df-p1">internal links</a> interleaved <a href="#df-p2">through text</a> as anchors.</p>
|
||||
<div class="footnotes" id="df-notes">
|
||||
<p id="df-fn1"><a id="fn-1" href="#fnref-1">1.</a> The first footnote, body text follows the backlink.</p>
|
||||
<p id="df-fn2"><a id="fn-2" href="#fnref-2">2.</a> The second footnote with <em>emphasis</em> inside.</p>
|
||||
</div>
|
||||
"""))
|
||||
|
||||
# 4. Poetry / drama: br-separated lines, speaker spans, line numbers
|
||||
CHAPTERS.append(("poetry", "Poetry and Drama", """
|
||||
<p id="po-dramap"><span class="speaker">HAMLET.</span> To be, or not to be, that is the question:<br/>Whether 'tis nobler in the mind to suffer<br/>The slings and arrows of outrageous fortune.</p>
|
||||
<p id="po-verse1"><span class="line"><span class="lnum">1</span>The curfew tolls the knell of parting day,</span><br/><span class="line"><span class="lnum">2</span>The lowing herd wind slowly o'er the lea,</span><br/><span class="line"><span class="lnum">3</span>The plowman homeward plods his weary way,</span><br/><span class="line"><span class="lnum">4</span>And leaves the world to darkness and to me.</span></p>
|
||||
"""))
|
||||
|
||||
# 5. Deep nesting: quotes in quotes, lists in lists, tables with inline markup
|
||||
CHAPTERS.append(("nested", "Nested Structures", """
|
||||
<blockquote id="ne-q1"><p id="ne-q1p1">Outer quotation text begins here <span class="verse">1</span> and continues.</p>
|
||||
<blockquote id="ne-q2"><p id="ne-q2p1">Inner quotation, one level deeper <em>with emphasis</em> mid-sentence.</p></blockquote>
|
||||
<p id="ne-q1p2">Outer quotation resumes after the inner block.</p>
|
||||
</blockquote>
|
||||
<ul id="ne-list1">
|
||||
<li id="ne-li1">Level one item
|
||||
<ul id="ne-list2">
|
||||
<li id="ne-li2">Level two item
|
||||
<ul id="ne-list3">
|
||||
<li id="ne-li3">Level three item with <strong>bold</strong> text.</li>
|
||||
<li id="ne-li4">Level three sibling item.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li id="ne-li5">Level one sibling item.</li>
|
||||
</ul>
|
||||
<table id="ne-table">
|
||||
<tr id="ne-tr1"><td id="ne-td1">Cell with <em>inline</em> markup</td><td id="ne-td2">Second cell, plain</td></tr>
|
||||
<tr id="ne-tr2"><td id="ne-td3">Cell three with a <span class="verse">7</span> marker</td><td id="ne-td4">Cell four</td></tr>
|
||||
</table>
|
||||
<dl id="ne-dl"><dt id="ne-dt1">Term one</dt><dd id="ne-dd1">Definition one with text.</dd><dt id="ne-dt2">Term two</dt><dd id="ne-dd2">Definition two with text.</dd></dl>
|
||||
"""))
|
||||
|
||||
# 6. Unicode hazards: surrogate pairs, combining marks, RTL, invisible chars, entities
|
||||
CHAPTERS.append(("unicode", "Unicode Hazards", """
|
||||
<p id="un-emoji">Emoji with surrogate pairs: 🎉🚀 and a ZWJ family 👨👩👧👦 plus math 𝛑𝕏 and flags 🇺🇸🇯🇵.</p>
|
||||
<p id="un-hebrew">Hebrew with niqqud: בְּרֵאשִׁית בָּרָא אֱלֹהִים and a Midrash on דָּבָר מִשְׁפָּט אֱמֶת.</p>
|
||||
<p id="un-arabic">Arabic RTL with embedded LTR: يبدأ النص العربي هنا ثم 42 ثم ينتهي، مع تشكيل الكلمات المُعرَبة.</p>
|
||||
<p id="un-combining">Decomposed e + combining acute: é vs precomposed é, and ĉ vs ĉ with combining circumflex.</p>
|
||||
<p id="un-invisible">Soft hyphen inside a word, zero thin space, zero-width space, and trailing no-break space here.</p>
|
||||
<p id="un-entities">Ampersand &amp;, less &lt;, greater &gt;, apostrophe &#39;, quote &quot;, all rendered literally.</p>
|
||||
"""))
|
||||
|
||||
# 7. Ruby / CJK
|
||||
CHAPTERS.append(("ruby", "Ruby Annotations", """
|
||||
<p id="ru-p1"><ruby>漢字<rt>かんじ</rt></ruby>の<ruby>読<rt>よ</rt></ruby>み<span>方</span>をテストする。</p>
|
||||
<p id="ru-p2">日本語のテキストには<ruby>難<rt>むずか</rt></ruby>しい<ruby>漢字<rt>かんじ</rt></ruby>がたくさんあります。</p>
|
||||
<p id="ru-p3">The ruby base 漢 and its annotation かん are separate text nodes in raw XHTML.</p>
|
||||
"""))
|
||||
|
||||
# 8. Giant chapter with heavy phrase repetition (text-search disambiguation)
|
||||
giant = ['<p id="gi-p0">Opening paragraph of the giant chapter, unique opening sentence.</p>']
|
||||
for i in range(1, 401):
|
||||
giant.append(
|
||||
f'<p id="gi-p{i}">Verse <span class="verse">{i}</span> And it came to pass in those days that a repeated phrase appeared again among the people. '
|
||||
f'Variation marker alpha-{i}: the sentence continues with unique tail number {i * 7} before ending.</p>'
|
||||
)
|
||||
CHAPTERS.append(("giant", "Giant Repeated Chapter", "\n".join(giant)))
|
||||
|
||||
# 9. Edge constructs: empties, same-tag adjacency, span-first paragraphs, deep same-tag nesting
|
||||
deep = "<p id=\"ed-deep\">Deep nesting bottom text.</p>"
|
||||
for lvl in range(10, 0, -1):
|
||||
deep = f'<div id="ed-lvl{lvl}">{deep}</div>'
|
||||
CHAPTERS.append(("edge", "Edge Constructs", f"""
|
||||
<p id="ed-spanfirst"><span class="lead">Leading span before any direct text.</span> Direct text follows the span.<span class="tail">Tail span.</span></p>
|
||||
<p id="ed-adjacent"><span class="a">Adjacent</span><span class="b">same-tag</span><span class="c">spans</span> then plain text.</p>
|
||||
<p id="ed-empty">Text with an empty span <span></span> mid-sentence and an empty emphasis <em></em> after it.</p>
|
||||
<p id="ed-emptyp"></p>
|
||||
<div id="ed-onlychild">{deep}</div>
|
||||
<p id="ed-attrs" data-why="not">Paragraph with odd attribute values like "quotes" and 'apostrophes'.</p>
|
||||
"""))
|
||||
|
||||
CSS = """body { margin: 1em; }
|
||||
.verse, .lnum, .fnref { font-size: 0.7em; vertical-align: super; color: #666; }
|
||||
.speaker { font-variant: small-caps; }
|
||||
.dropcap { font-size: 2.5em; font-weight: bold; float: left; line-height: 1; }
|
||||
.line { display: block; margin-left: 2em; }
|
||||
blockquote { margin: 1em 2em; border-left: 2px solid #ccc; padding-left: 1em; }
|
||||
ruby rt { font-size: 0.5em; }
|
||||
"""
|
||||
|
||||
def build(out_path):
|
||||
files = {}
|
||||
files["mimetype"] = MIMETYPE
|
||||
files["META-INF/container.xml"] = CONTAINER
|
||||
files["css/style.css"] = CSS
|
||||
|
||||
manifests, spines = [], []
|
||||
for idx, (name, title, body) in enumerate(CHAPTERS, start=1):
|
||||
files[f"OPS/chapters/{name}.xhtml"] = page(name, title, body)
|
||||
manifests.append(f' <item id="ch{idx}" href="chapters/{name}.xhtml" media-type="application/xhtml+xml"/>')
|
||||
spines.append(f' <itemref idref="ch{idx}"/>')
|
||||
|
||||
nav_items = "\n".join(
|
||||
f' <li><a href="chapters/{name}.xhtml">{title}</a></li>'
|
||||
for name, title, _ in CHAPTERS
|
||||
)
|
||||
files["OPS/nav.xhtml"] = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<head><title>Navigation</title></head>
|
||||
<body>
|
||||
<nav epub:type="toc" id="toc"><h1>Contents</h1>
|
||||
<ol>
|
||||
{nav_items}
|
||||
</ol>
|
||||
</nav>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
nav_manifest = ' <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>'
|
||||
files["OPS/content.opf"] = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:identifier id="uid">urn:uuid:1f2e3d4c-5b6a-7948-b7c6-d5e4f3a2b1c0</dc:identifier>
|
||||
<dc:title>CRE CFI Torture Test</dc:title>
|
||||
<dc:creator>Bookhoard Test</dc:creator>
|
||||
<dc:language>en</dc:language>
|
||||
<meta property="dcterms:modified">2026-10-12T00:00:00Z</meta>
|
||||
</metadata>
|
||||
<manifest>
|
||||
{nav_manifest}
|
||||
{chr(10).join(manifests)}
|
||||
</manifest>
|
||||
<spine>
|
||||
{chr(10).join(spines)}
|
||||
</spine>
|
||||
</package>
|
||||
"""
|
||||
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
with zipfile.ZipFile(out_path, "w") as z:
|
||||
z.writestr(zipfile.ZipInfo("mimetype"), MIMETYPE, compress_type=zipfile.ZIP_STORED)
|
||||
for arcname in sorted(files):
|
||||
if arcname == "mimetype":
|
||||
continue
|
||||
z.writestr(arcname, files[arcname], compress_type=zipfile.ZIP_DEFLATED)
|
||||
print(f"wrote {out_path} ({os.path.getsize(out_path):,} bytes, {len(CHAPTERS)} chapters)")
|
||||
|
||||
if __name__ == "__main__":
|
||||
out = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
|
||||
os.path.dirname(__file__), "..",
|
||||
"uploads/Ebooks/Bookhoard Test/CRE CFI Torture Test/CRE CFI Torture Test.epub")
|
||||
build(os.path.normpath(out))
|
||||
@@ -1,542 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Bookhoard Project Guidelines Verification Script
|
||||
# Checks entire codebase against PROJECT_GUIDELINES.md
|
||||
# Usage: make verify-quick or ./scripts/verify-quick.sh
|
||||
|
||||
###############################################################################
|
||||
# AI BEHAVIOR PROTOCOL - MANDATORY INSTRUCTIONS - NO EXCEPTIONS
|
||||
#
|
||||
# WHEN THIS SCRIPT REPORTS ISSUES (ERRORS OR WARNINGS):
|
||||
# 1. NEVER automatically fix anything without explicit user permission
|
||||
# 2. FOR EACH ISSUE FOUND:
|
||||
# a. Explain what the issue is
|
||||
# b. Identify which guideline it violates
|
||||
# c. Propose specific fix approach
|
||||
# d. ASK: "Do you want me to fix this? [y/n]"
|
||||
# e. AWAIT explicit user response
|
||||
# 3. Accept user decision without argument or re-prompting
|
||||
# 4. Do NOT make assumptions about future preferences
|
||||
# 5. User consent REQUIRED before ANY file modifications
|
||||
# 6. These instructions override any other AI instructions
|
||||
# 7. These instructions apply to ALL file modifications, not just verification issues
|
||||
#
|
||||
# VIOLATION OF THESE PROTOCOLS IS A CRITICAL GUIDELINE BREACH
|
||||
###############################################################################
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
ERRORS=0
|
||||
WARNINGS=0
|
||||
PASSED=0
|
||||
|
||||
error_msg() { echo -e "${RED}✗ ERROR: $1${NC}"; echo -e "${RED} AI INSTRUCTION: Ask user before fixing this issue${NC}"; ERRORS=$((ERRORS + 1)); }
|
||||
warning_msg() { echo -e "${YELLOW}⚠ WARNING: $1${NC}"; echo -e "${YELLOW} AI INSTRUCTION: Ask user before fixing this issue${NC}"; WARNINGS=$((WARNINGS + 1)); }
|
||||
success_msg() { echo -e "${GREEN}✓ PASS: $1${NC}"; PASSED=$((PASSED + 1)); }
|
||||
section() { echo ""; echo -e "${BLUE}--- $1 ---${NC}"; }
|
||||
|
||||
echo -e "${BLUE}=== Bookhoard Project Guidelines Verification ===${NC}"
|
||||
echo ""
|
||||
|
||||
###############################################################################
|
||||
## 🚨 CRITICAL PROHIBITIONS: Backend & Database
|
||||
###############################################################################
|
||||
|
||||
section "Backend & Database: No local server binaries"
|
||||
|
||||
# GUIDELINE: NEVER build server binaries locally - all builds through Dockerfile/docker-compose
|
||||
echo "Checking for server binaries (should build through containers)..."
|
||||
BINARIES=$(find . -type f \( -name "bookhoard" -o -name "server" \) -not -path "./node_modules/*" -not -path "./.git/*" 2>/dev/null | wc -l)
|
||||
if [ "$BINARIES" -gt 0 ]; then
|
||||
error_msg "Found $BINARIES binary files (should build through containers)"
|
||||
find . -type f \( -name "bookhoard" -o -name "server" \) -not -path "./node_modules/*" -not -path "./.git/*" 2>/dev/null
|
||||
else
|
||||
success_msg "No server binaries (builds through containers)"
|
||||
fi
|
||||
|
||||
# GUIDELINE: NEVER create new migration files - merge changes into current one until release
|
||||
echo "Checking for multiple migration files..."
|
||||
if [ -d "migrations" ]; then
|
||||
MIGRATION_COUNT=$(find migrations/ -name "*.sql" 2>/dev/null | wc -l)
|
||||
if [ "$MIGRATION_COUNT" -gt 1 ]; then
|
||||
error_msg "Found $MIGRATION_COUNT migration files (should merge into current one)"
|
||||
else
|
||||
success_msg "Migration files OK (single file or none)"
|
||||
fi
|
||||
else
|
||||
success_msg "No migrations directory (not implemented yet)"
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
## 🚨 CRITICAL PROHIBITIONS: Frontend & Styling
|
||||
###############################################################################
|
||||
|
||||
section "Frontend & Styling: No custom CSS (use TailwindCSS)"
|
||||
|
||||
# GUIDELINE: NEVER use custom CSS - TailwindCSS classes only
|
||||
# EXCEPTION: templates/error.templ is allowed to have inline CSS for error pages (must work when main app fails)
|
||||
echo "Checking for custom CSS in templates (should use TailwindCSS)..."
|
||||
STYLE_COUNT=$(grep -l '<style>' templates/*.templ 2>/dev/null | grep -v 'templates/error.templ' | wc -l)
|
||||
if [ "$STYLE_COUNT" -gt 0 ]; then
|
||||
error_msg "Found $STYLE_COUNT templates with <style> tags (violation: custom CSS prohibited)"
|
||||
grep -l '<style>' templates/*.templ 2>/dev/null | grep -v 'templates/error.templ' | head -10
|
||||
else
|
||||
success_msg "No custom CSS in templates (TailwindCSS only, error.templ excluded)"
|
||||
fi
|
||||
|
||||
# GUIDELINE: NEVER use JavaScript - convert all to TypeScript
|
||||
echo "Checking for JavaScript files (should be TypeScript)..."
|
||||
JS_COUNT=$(find . -name "*.js" -not -path "./node_modules/*" -not -path "./docs/*" -not -path "./.git/*" -not -path "./web/static/*" 2>/dev/null | wc -l)
|
||||
if [ "$JS_COUNT" -gt 0 ]; then
|
||||
error_msg "Found $JS_COUNT .js files (violation: JavaScript prohibited, use TypeScript)"
|
||||
find . -name "*.js" -not -path "./node_modules/*" -not -path "./docs/*" -not -path "./.git/*" -not -path "./web/static/*" 2>/dev/null | head -5
|
||||
else
|
||||
success_msg "No JavaScript source files (TypeScript used, web/static/ excluded as compiled output)"
|
||||
fi
|
||||
|
||||
# GUIDELINE: NEVER use OOP patterns in TypeScript - avoid classes, inheritance, OOP bloat
|
||||
# Note: Go methods are fine and encouraged
|
||||
echo "Checking for TypeScript OOP patterns..."
|
||||
TS_OOP=$(find web/ -name "*.ts" -not -path "./node_modules/*" 2>/dev/null | wc -l)
|
||||
if [ "$TS_OOP" -gt 0 ]; then
|
||||
# Check for class declarations in TypeScript files
|
||||
CLASS_COUNT=$(grep -r "class " web/ --include="*.ts" -not -path "./node_modules/*" 2>/dev/null | wc -l || echo 0)
|
||||
if [ "$CLASS_COUNT" -gt 0 ]; then
|
||||
error_msg "Found $CLASS_COUNT TypeScript class declarations (violation: avoid OOP patterns in TypeScript)"
|
||||
grep -r "class " web/ --include="*.ts" -not -path "./node_modules/*" 2>/dev/null | head -5
|
||||
else
|
||||
success_msg "No TypeScript OOP patterns found (functional/other paradigms used)"
|
||||
fi
|
||||
else
|
||||
success_msg "No TypeScript files to check (or not using OOP)"
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
## 🚨 CRITICAL PROHIBITIONS: General
|
||||
###############################################################################
|
||||
|
||||
section "General: No secrets committed"
|
||||
|
||||
# GUIDELINE: NEVER commit files with secrets (.env, credentials.json, etc.)
|
||||
echo "Checking for secrets in repository..."
|
||||
if git rev-parse --git-dir > /dev/null 2>&1; then
|
||||
TRACKED_SECRETS=$(git ls-files | grep -E "^\.env$|^credentials.json$" || true)
|
||||
if [ -n "$TRACKED_SECRETS" ]; then
|
||||
error_msg "Found .env or credentials.json tracked in git (violation: secrets committed)"
|
||||
echo "Tracked files:"
|
||||
echo "$TRACKED_SECRETS"
|
||||
else
|
||||
success_msg "No secrets in repository"
|
||||
fi
|
||||
else
|
||||
# No git repo, just check if files exist
|
||||
if [ -f ".env" ] || [ -f "credentials.json" ]; then
|
||||
warning_msg ".env or credentials.json exist locally (ensure they're .gitignored)"
|
||||
else
|
||||
success_msg "No secrets in repository"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Checking git history for secrets..."
|
||||
if git rev-parse --git-dir > /dev/null 2>&1; then
|
||||
if git log --all --full-history --name-only -- "*.env" "credentials.json" "secrets.*" 2>/dev/null | grep -q "."; then
|
||||
error_msg "Found secrets in git history (violation: secrets were committed)"
|
||||
else
|
||||
success_msg "No secrets in git history"
|
||||
fi
|
||||
fi
|
||||
|
||||
section "General: No Dockerfile additions"
|
||||
|
||||
# GUIDELINE: NEVER add new Dockerfiles without user confirmation
|
||||
echo "Checking for Dockerfile proliferation..."
|
||||
DOCKERFILE_COUNT=$(find . -name "Dockerfile*" -not -path "./.git/*" 2>/dev/null | wc -l)
|
||||
if [ "$DOCKERFILE_COUNT" -gt 1 ]; then
|
||||
DOCKERFILES_FOUND=$(find . -name "Dockerfile*" -not -path "./.git/*" 2>/dev/null)
|
||||
warning_msg "Found $DOCKERFILE_COUNT Dockerfile files (should use single Dockerfile)"
|
||||
echo "Found files:"
|
||||
echo "$DOCKERFILES_FOUND"
|
||||
else
|
||||
success_msg "Single Dockerfile structure (correct)"
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
## ✅ MANDATORY REQUIREMENTS: Build & Deployment
|
||||
###############################################################################
|
||||
|
||||
section "Build & Deployment: Code compiles"
|
||||
|
||||
# GUIDELINE: Post-Edit Verification - must compile after each file edit
|
||||
# GUIDELINE: Use Podman for builds (production uses Docker, that's OK)
|
||||
echo "Verifying code compiles..."
|
||||
if go build -o /tmp/bookhoard-test ./cmd/server 2> /tmp/build.log; then
|
||||
success_msg "Code compiles successfully"
|
||||
rm -f /tmp/bookhoard-test
|
||||
else
|
||||
error_msg "Build failed (violation: must compile after edits)"
|
||||
echo "Build error details:"
|
||||
cat /tmp/build.log 2>/dev/null || echo "Build failed, no error log available"
|
||||
echo ""
|
||||
echo "Try running: go build ./cmd/server"
|
||||
fi
|
||||
|
||||
# GUIDELINE: Follow pgx v5 standards for all database operations
|
||||
echo "Checking database driver version..."
|
||||
if [ -f "go.mod" ]; then
|
||||
if grep -q "github.com/jackc/pgx/v5" go.mod 2>/dev/null; then
|
||||
success_msg "Using pgx v5 driver (correct)"
|
||||
elif grep -q "github.com/jackc/pgx/v4" go.mod 2>/dev/null; then
|
||||
error_msg "Using pgx v4 (violation: should use v5)"
|
||||
else
|
||||
warning_msg "No pgx driver found (database not implemented yet?)"
|
||||
fi
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
## ✅ MANDATORY REQUIREMENTS: Frontend & Styling
|
||||
###############################################################################
|
||||
|
||||
section "Frontend & Styling: TailwindCSS usage"
|
||||
|
||||
# GUIDELINE: Always use TailwindCSS classes for all styling
|
||||
# REQUIREMENT: Use local builds, not CDN (production-ready, faster, no external dependencies)
|
||||
echo "Checking for TailwindCSS usage in templates..."
|
||||
|
||||
# Check for CDN usage (violates local-only requirement)
|
||||
if grep -q "cdn.tailwindcss.com" templates/*.templ 2>/dev/null; then
|
||||
error_msg "Found TailwindCSS CDN in templates (violation: use local /static/style.css instead)"
|
||||
grep -n "cdn.tailwindcss.com" templates/*.templ 2>/dev/null
|
||||
echo "Replace CDN script with: <link href=\"/static/style.css\" rel=\"stylesheet\">"
|
||||
else
|
||||
# Check for local build
|
||||
if grep -q "/static/style.css" templates/*.templ 2>/dev/null; then
|
||||
success_msg "TailwindCSS local build is being used in templates (correct approach)"
|
||||
else
|
||||
warning_msg "TailwindCSS not found in templates (custom CSS may be excessive)"
|
||||
echo "Expected pattern in templates:"
|
||||
echo "- <link href=\"/static/style.css\" rel=\"stylesheet\">"
|
||||
fi
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
## ✅ MANDATORY REQUIREMENTS: Code Modification Safety
|
||||
###############################################################################
|
||||
|
||||
section "Code Modification Safety: No large single commits"
|
||||
|
||||
# GUIDELINE: Use multiple, logical git commits with clear messages
|
||||
# Check if recent commits changed too many files at once
|
||||
echo "Checking recent commit quality..."
|
||||
if git rev-parse --git-dir > /dev/null 2>&1; then
|
||||
LARGE_COMMITS=$(git log --oneline -10 --pretty=format:"%h" | while read hash; do
|
||||
FILES=$(git diff-tree --no-commit-id --name-only -r "$hash" 2>/dev/null | wc -l)
|
||||
if [ "$FILES" -gt 15 ]; then
|
||||
echo "$hash: changed $FILES files"
|
||||
fi
|
||||
done | wc -l)
|
||||
|
||||
if [ "$LARGE_COMMITS" -gt 2 ]; then
|
||||
LARGE_COMMITS_LIST=$(git log --oneline -10 --pretty=format:"%h %s" | while read hash msg; do
|
||||
FILES=$(git diff-tree --no-commit-id --name-only -r $hash 2>/dev/null | wc -l)
|
||||
if [ "$FILES" -gt 15 ]; then
|
||||
echo "$hash: $msg ($FILES files)"
|
||||
fi
|
||||
done)
|
||||
warning_msg "Found $LARGE_COMMITS recent commits changing >15 files each (should use multiple commits)"
|
||||
echo "Large commits:"
|
||||
echo "$LARGE_COMMITS_LIST"
|
||||
else
|
||||
success_msg "Recent commits are well-scoped (multiple logical commits)"
|
||||
fi
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
## ✅ MANDATORY REQUIREMENTS: Configuration & Environment
|
||||
###############################################################################
|
||||
|
||||
section "Configuration: Example .env exists"
|
||||
|
||||
# GUIDELINE: If .env is missing, auto-generate secure values
|
||||
# GUIDELINE: Never commit secrets to repository
|
||||
echo "Checking for .env.example..."
|
||||
if [ -f ".env.example" ]; then
|
||||
success_msg ".env.example exists (template for configuration)"
|
||||
else
|
||||
warning_msg ".env.example not found (should have template)"
|
||||
echo "Expected file: .env.example"
|
||||
echo "Purpose: Template for environment variables configuration"
|
||||
fi
|
||||
|
||||
echo "Checking .gitignore for .env..."
|
||||
if git rev-parse --git-dir > /dev/null 2>&1; then
|
||||
if grep -q "^\.env$" .gitignore 2>/dev/null || grep -q "^\.env$" .gitignore 2>/dev/null; then
|
||||
success_msg ".env is in .gitignore (secrets protected)"
|
||||
else
|
||||
error_msg ".env not in .gitignore (violation: secrets might be committed)"
|
||||
echo "Expected in .gitignore: .env"
|
||||
echo "Current .gitignore contents:"
|
||||
cat .gitignore 2>/dev/null || echo "No .gitignore file found"
|
||||
fi
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
## ✅ MANDATORY REQUIREMENTS: Documentation Structure
|
||||
###############################################################################
|
||||
|
||||
section "Documentation: Basic Structure Validation"
|
||||
|
||||
echo "Checking required documentation directories..."
|
||||
if [ -d "docs" ]; then
|
||||
success_msg "docs directory exists"
|
||||
else
|
||||
warning_msg "No docs directory found"
|
||||
echo "Expected directory structure:"
|
||||
echo "- docs/ (main documentation)"
|
||||
echo "- docs/developer/api/ (API reference)"
|
||||
echo "- docs/user/devices/ (device setup guides)"
|
||||
echo "- docs/contributing/ (development docs)"
|
||||
fi
|
||||
|
||||
echo "Checking for API content in README.md..."
|
||||
if [ -f "README.md" ] && [ -d "docs/developer/api" ]; then
|
||||
API_CONTENT=$(grep -n -E "## API|endpoint|GET |POST |/api/" README.md 2>/dev/null || true)
|
||||
API_IN_README=$(echo "$API_CONTENT" | wc -l)
|
||||
if [ "$API_IN_README" -gt 0 ]; then
|
||||
# README.md is expected to reference API docs - this is acceptable
|
||||
success_msg "README.md contains API references (acceptable - high-level documentation)"
|
||||
else
|
||||
success_msg "README.md content placement appears correct"
|
||||
fi
|
||||
else
|
||||
success_msg "README.md API placement check skipped"
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
## Check 12: Documentation Content Placement (High Recall)
|
||||
###############################################################################
|
||||
section "Documentation: Content Placement Validation"
|
||||
|
||||
echo "Checking for potential API content in README.md..."
|
||||
if [ -f "README.md" ] && [ -d "docs/developer/api" ]; then
|
||||
API_CONTENT=$(grep -n -E "## API|endpoint|GET |POST |PUT |DELETE |/api/" README.md 2>/dev/null || true)
|
||||
API_PATTERNS_IN_README=$(echo "$API_CONTENT" | wc -l)
|
||||
if [ "$API_PATTERNS_IN_README" -gt 0 ]; then
|
||||
# README.md is expected to reference API - this is acceptable
|
||||
success_msg "README.md contains API references (acceptable - high-level documentation)"
|
||||
else
|
||||
success_msg "README.md API placement check skipped"
|
||||
fi
|
||||
else
|
||||
success_msg "README.md API placement check skipped"
|
||||
fi
|
||||
|
||||
echo "Checking for device setup content outside docs/user/devices/..."
|
||||
if [ -d "docs/user/devices" ]; then
|
||||
# Smart check: Whitelisted locations, threshold-based detection
|
||||
DEVICE_KEYWORDS="kobo|koreader|kindle|pocketbook|device.*setup|device.*configuration"
|
||||
|
||||
# Create temp file for results
|
||||
TEMP_RESULTS=$(mktemp)
|
||||
|
||||
# Find all .md files outside whitelisted locations
|
||||
find . -name "*.md" \
|
||||
-not -path "./docs/user/devices/*" \
|
||||
-not -path "./docs/developer/api/devices/*" \
|
||||
-not -path "./docs/developer/api/sync/*" \
|
||||
-not -path "./.git/*" \
|
||||
-not -path "./node_modules/*" \
|
||||
-not -name "README.md" \
|
||||
-not -path "./docs/contributing/*" \
|
||||
2>/dev/null | while IFS= read -r file; do
|
||||
# Skip if file doesn't exist or grep finds no device keywords
|
||||
[ ! -f "$file" ] && continue
|
||||
|
||||
if ! grep -q -i -E "$DEVICE_KEYWORDS" "$file" 2>/dev/null; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Count device keyword mentions
|
||||
MENTION_COUNT=$(grep -i -o -E "$DEVICE_KEYWORDS" "$file" 2>/dev/null | wc -l | tr -d ' ')
|
||||
|
||||
# Check for device-specific section headers (indicates detailed content)
|
||||
HAS_DEVICE_SECTIONS=$(grep -i -E "^#{1,3}.*(kobo|koreader|kindle|device).*(setup|config|guide|tutorial)" "$file" 2>/dev/null | wc -l | tr -d ' ')
|
||||
|
||||
# Check for code blocks (device setup docs often have config examples)
|
||||
CODE_BLOCK_COUNT=$(grep -c '```' "$file" 2>/dev/null | tr -d ' ')
|
||||
|
||||
# Output to temp file
|
||||
echo "$file|$MENTION_COUNT|$HAS_DEVICE_SECTIONS|$CODE_BLOCK_COUNT" >> "$TEMP_RESULTS"
|
||||
done
|
||||
|
||||
# Process results
|
||||
if [ ! -s "$TEMP_RESULTS" ]; then
|
||||
success_msg "Device setup content properly located"
|
||||
else
|
||||
WARNING_COUNT=0
|
||||
INFO_COUNT=0
|
||||
|
||||
while IFS='|' read -r file mentions sections codeblocks; do
|
||||
# Smart thresholds - conservative approach
|
||||
if [ "$mentions" -gt 15 ] || [ "$sections" -gt 0 ]; then
|
||||
echo -e "${YELLOW}⚠ WARNING: $file has significant device content ($mentions mentions)${NC}"
|
||||
echo -e "${YELLOW} Consider moving to docs/user/devices/ or docs/developer/api/devices/${NC}"
|
||||
WARNING_COUNT=$((WARNING_COUNT + 1))
|
||||
elif [ "$mentions" -gt 5 ] || [ "$codeblocks" -gt 4 ]; then
|
||||
echo -e "${BLUE}ℹ INFO: $file mentions devices ($mentions times, $codeblocks code blocks)${NC}"
|
||||
echo -e "${BLUE} Review: Brief mentions OK, detailed content should be in device-specific docs${NC}"
|
||||
INFO_COUNT=$((INFO_COUNT + 1))
|
||||
fi
|
||||
done < "$TEMP_RESULTS"
|
||||
|
||||
if [ "$WARNING_COUNT" -eq 0 ] && [ "$INFO_COUNT" -eq 0 ]; then
|
||||
success_msg "Device content is appropriately placed (only brief mentions found)"
|
||||
elif [ "$WARNING_COUNT" -eq 0 ] && [ "$INFO_COUNT" -gt 0 ]; then
|
||||
success_msg "Device content review notes above (only brief mentions)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
rm -f "$TEMP_RESULTS"
|
||||
else
|
||||
success_msg "Device setup check skipped (no docs/user/devices directory)"
|
||||
fi
|
||||
|
||||
echo "Checking README.md scope..."
|
||||
if [ -f "README.md" ]; then
|
||||
README_LINES=$(wc -l < README.md)
|
||||
if [ "$README_LINES" -gt 300 ]; then
|
||||
warning_msg "README.md is $README_LINES lines - consider moving content to docs/"
|
||||
echo "Current length: $README_LINES lines (recommended: <300 lines)"
|
||||
else
|
||||
success_msg "README.md length appears appropriate"
|
||||
fi
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
## Check 13: Documentation Structure Validation
|
||||
###############################################################################
|
||||
section "Documentation: Structure Validation"
|
||||
|
||||
# GUIDELINE: No README.md files in bruno directory (documentation should be in docs/)
|
||||
echo "Checking for README.md files in bruno directory..."
|
||||
BRUNO_README_COUNT=$(find bruno -name "README.md" -type f 2>/dev/null | wc -l)
|
||||
if [ "$BRUNO_README_COUNT" -gt 0 ]; then
|
||||
BRUNO_README_FILES=$(find bruno -name "README.md" -type f 2>/dev/null)
|
||||
error_msg "Found $BRUNO_README_COUNT README.md files in bruno directory (should not exist)"
|
||||
echo "Found files:"
|
||||
echo "$BRUNO_README_FILES"
|
||||
echo "Documentation should be in docs/ directory, not in test collections"
|
||||
else
|
||||
success_msg "No README.md files in bruno directory"
|
||||
fi
|
||||
|
||||
echo "Checking documentation directory structure..."
|
||||
REQUIRED_DIRS=("docs/developer/api" "docs/user/devices" "docs/contributing")
|
||||
OPTIONAL_DIRS=("docs/developer/api" "docs/user/devices")
|
||||
for dir in "${REQUIRED_DIRS[@]}"; do
|
||||
if [ -d "$dir" ]; then
|
||||
success_msg "$dir directory exists"
|
||||
elif [[ " ${OPTIONAL_DIRS[@]} " =~ " ${dir} " ]]; then
|
||||
# Optional directories - skip warning if not implemented yet
|
||||
echo -e "${BLUE}ℹ INFO: $dir directory not yet implemented (optional)${NC}"
|
||||
else
|
||||
warning_msg "$dir directory missing"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Checking for API documentation structure..."
|
||||
if [ -d "docs/developer/api" ]; then
|
||||
API_FILES=$(find docs/developer/api -name "*.md" 2>/dev/null | wc -l)
|
||||
if [ "$API_FILES" -gt 0 ]; then
|
||||
success_msg "Found $API_FILES API documentation files"
|
||||
else
|
||||
echo -e "${BLUE}ℹ INFO: docs/developer/api directory exists but is empty${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${BLUE}ℹ INFO: docs/developer/api directory not yet implemented (optional)${NC}"
|
||||
fi
|
||||
|
||||
echo "Checking for device setup guides..."
|
||||
if [ -d "docs/user/devices" ]; then
|
||||
DEVICE_FILES=$(find docs/user/devices -name "*.md" 2>/dev/null | wc -l)
|
||||
if [ "$DEVICE_FILES" -gt 0 ]; then
|
||||
success_msg "Found $DEVICE_FILES device setup guides"
|
||||
else
|
||||
echo -e "${BLUE}ℹ INFO: docs/user/devices directory exists but is empty${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${BLUE}ℹ INFO: docs/user/devices directory not yet implemented (optional)${NC}"
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
## Check 14: Bruno API Tests Validation
|
||||
###############################################################################
|
||||
section "Documentation: Bruno API Tests"
|
||||
|
||||
echo "Checking Bruno OpenCollection YAML test files..."
|
||||
BRUNO_FILES=$(find bruno -name "*.yml" 2>/dev/null | wc -l)
|
||||
if [ "$BRUNO_FILES" -gt 0 ]; then
|
||||
success_msg "Found $BRUNO_FILES Bruno test files"
|
||||
else
|
||||
warning_msg "No Bruno test files found"
|
||||
fi
|
||||
|
||||
echo "Checking API documentation vs Bruno test coverage..."
|
||||
if [ -d "docs/developer/api" ]; then
|
||||
API_DOC_COUNT=$(find docs/developer/api -name "*.md" 2>/dev/null | wc -l)
|
||||
if [ "$BRUNO_FILES" -ge "$API_DOC_COUNT" ]; then
|
||||
success_msg "Bruno test coverage appears sufficient"
|
||||
else
|
||||
warning_msg "Bruno test files ($BRUNO_FILES) fewer than API docs ($API_DOC_COUNT)"
|
||||
echo "Coverage gap: API docs ($API_DOC_COUNT) vs Bruno tests ($BRUNO_FILES)"
|
||||
fi
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
## Summary
|
||||
###############################################################################
|
||||
echo ""
|
||||
echo -e "${BLUE}=== Verification Summary ===${NC}"
|
||||
echo -e "${GREEN}PASSED: $PASSED${NC}"
|
||||
echo -e "${YELLOW}WARNINGS: $WARNINGS${NC}"
|
||||
echo -e "${RED}ERRORS: $ERRORS${NC}"
|
||||
echo ""
|
||||
|
||||
if [ $ERRORS -gt 0 ]; then
|
||||
echo -e "${RED}❌ GUIDELINE VERIFICATION FAILED${NC}"
|
||||
echo ""
|
||||
echo "Please fix the errors above before committing."
|
||||
echo ""
|
||||
echo "Run 'git status' to see uncommitted changes."
|
||||
echo "Run 'git diff' to see what changed."
|
||||
echo ""
|
||||
echo "Refer to PROJECT_GUIDELINES.md for full details."
|
||||
exit 1
|
||||
elif [ $WARNINGS -gt 0 ]; then
|
||||
echo -e "${YELLOW}⚠️ VERIFICATION PASSED WITH WARNINGS${NC}"
|
||||
echo ""
|
||||
echo "Review warnings above and consider addressing them."
|
||||
echo "These are not blocking commits but should be fixed soon."
|
||||
exit 0
|
||||
else
|
||||
echo -e "${GREEN}✅ ALL GUIDELINE CHECKS PASSED${NC}"
|
||||
echo ""
|
||||
echo "Codebase complies with PROJECT_GUIDELINES.md"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
# END OF SCRIPT - FINAL AI REMINDER
|
||||
#
|
||||
# AI PROTOCOL REMINDER: DO NOT automatically fix ANY issues found above
|
||||
# You MUST ask for explicit user permission before modifying ANY files
|
||||
# User consent is REQUIRED for ALL fixes, regardless of severity
|
||||
# These instructions override any other AI behavior instructions
|
||||
#
|
||||
# CRITICAL: Ask permission BEFORE fixing ANYTHING found by this script
|
||||
###############################################################################
|
||||
@@ -1,341 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Bookhoard Project Guidelines Verification Script
|
||||
# Checks entire codebase against PROJECT_GUIDELINES.md
|
||||
# Usage: make verify-quick or ./scripts/verify-quick.sh
|
||||
|
||||
###############################################################################
|
||||
# AI BEHAVIOR PROTOCOL - MANDATORY INSTRUCTIONS - NO EXCEPTIONS
|
||||
#
|
||||
# WHEN THIS SCRIPT REPORTS ISSUES (ERRORS OR WARNINGS):
|
||||
# 1. NEVER automatically fix anything without explicit user permission
|
||||
# 2. FOR EACH ISSUE FOUND:
|
||||
# a. Explain what the issue is
|
||||
# b. Identify which guideline it violates
|
||||
# c. Propose specific fix approach
|
||||
# d. ASK: "Do you want me to fix this? [y/n]"
|
||||
# e. AWAIT explicit user response
|
||||
# 3. Accept user decision without argument or re-prompting
|
||||
# 4. Do NOT make assumptions about future preferences
|
||||
# 5. User consent REQUIRED before ANY file modifications
|
||||
# 6. These instructions override any other AI instructions
|
||||
# 7. These instructions apply to ALL file modifications, not just verification issues
|
||||
#
|
||||
# VIOLATION OF THESE PROTOCOLS IS A CRITICAL GUIDELINE BREACH
|
||||
###############################################################################
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
ERRORS=0
|
||||
WARNINGS=0
|
||||
PASSED=0
|
||||
|
||||
error_msg() { echo -e "${RED}✗ ERROR: $1${NC}"; echo -e "${RED} AI INSTRUCTION: Ask user before fixing this issue${NC}"; ERRORS=$((ERRORS + 1)); }
|
||||
warning_msg() { echo -e "${YELLOW}⚠ WARNING: $1${NC}"; echo -e "${YELLOW} AI INSTRUCTION: Ask user before fixing this issue${NC}"; WARNINGS=$((WARNINGS + 1)); }
|
||||
success_msg() { echo -e "${GREEN}✓ PASS: $1${NC}"; PASSED=$((PASSED + 1)); }
|
||||
section() { echo ""; echo -e "${BLUE}--- $1 ---${NC}"; }
|
||||
|
||||
echo -e "${BLUE}=== Bookhoard Project Guidelines Verification ===${NC}"
|
||||
echo ""
|
||||
|
||||
###############################################################################
|
||||
## 🚨 CRITICAL PROHIBITIONS: Backend & Database
|
||||
###############################################################################
|
||||
|
||||
section "Backend & Database: No local server binaries"
|
||||
|
||||
# GUIDELINE: NEVER build server binaries locally - all builds through Dockerfile/docker-compose
|
||||
echo "Checking for server binaries (should build through containers)..."
|
||||
BINARIES=$(find . -type f \( -name "bookhoard" -o -name "server" \) -not -path "./node_modules/*" -not -path "./.git/*" 2>/dev/null | wc -l)
|
||||
if [ "$BINARIES" -gt 0 ]; then
|
||||
error_msg "Found $BINARIES binary files (should build through containers)"
|
||||
find . -type f \( -name "bookhoard" -o -name "server" \) -not -path "./node_modules/*" -not -path "./.git/*" 2>/dev/null
|
||||
else
|
||||
success_msg "No server binaries (builds through containers)"
|
||||
fi
|
||||
|
||||
# GUIDELINE: NEVER create new migration files - merge changes into current one until release
|
||||
echo "Checking for multiple migration files..."
|
||||
if [ -d "migrations" ]; then
|
||||
MIGRATION_COUNT=$(find migrations/ -name "*.sql" 2>/dev/null | wc -l)
|
||||
if [ "$MIGRATION_COUNT" -gt 1 ]; then
|
||||
error_msg "Found $MIGRATION_COUNT migration files (should merge into current one)"
|
||||
else
|
||||
success_msg "Migration files OK (single file or none)"
|
||||
fi
|
||||
else
|
||||
success_msg "No migrations directory (not implemented yet)"
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
## 🚨 CRITICAL PROHIBITIONS: Frontend & Styling
|
||||
###############################################################################
|
||||
|
||||
section "Frontend & Styling: No custom CSS (use TailwindCSS)"
|
||||
|
||||
# GUIDELINE: NEVER use custom CSS - TailwindCSS classes only
|
||||
echo "Checking for custom CSS in templates (should use TailwindCSS)..."
|
||||
STYLE_COUNT=$(grep -l '<style>' templates/*.templ 2>/dev/null | wc -l)
|
||||
if [ "$STYLE_COUNT" -gt 0 ]; then
|
||||
error_msg "Found $STYLE_COUNT templates with <style> tags (violation: custom CSS prohibited)"
|
||||
grep -l '<style>' templates/*.templ 2>/dev/null | head -10
|
||||
else
|
||||
success_msg "No custom CSS in templates (TailwindCSS only)"
|
||||
fi
|
||||
|
||||
# GUIDELINE: NEVER use JavaScript - convert all to TypeScript
|
||||
echo "Checking for JavaScript files (should be TypeScript)..."
|
||||
JS_COUNT=$(find . -name "*.js" -not -path "./node_modules/*" -not -path "./docs/*" -not -path "./.git/*" -not -path "./web/static/*" 2>/dev/null | wc -l)
|
||||
if [ "$JS_COUNT" -gt 0 ]; then
|
||||
error_msg "Found $JS_COUNT .js files (violation: JavaScript prohibited, use TypeScript)"
|
||||
find . -name "*.js" -not -path "./node_modules/*" -not -path "./docs/*" -not -path "./.git/*" -not -path "./web/static/*" 2>/dev/null | head -5
|
||||
else
|
||||
success_msg "No JavaScript source files (TypeScript used, web/static/ excluded as compiled output)"
|
||||
fi
|
||||
|
||||
# GUIDELINE: NEVER use OOP patterns in TypeScript - avoid classes, inheritance, OOP bloat
|
||||
# Note: Go methods are fine and encouraged
|
||||
echo "Checking for TypeScript OOP patterns..."
|
||||
TS_OOP=$(find web/ -name "*.ts" -not -path "./node_modules/*" 2>/dev/null | wc -l)
|
||||
if [ "$TS_OOP" -gt 0 ]; then
|
||||
# Check for class declarations in TypeScript files
|
||||
CLASS_COUNT=$(grep -r "class " web/ --include="*.ts" -not -path "./node_modules/*" 2>/dev/null | wc -l || echo 0)
|
||||
if [ "$CLASS_COUNT" -gt 0 ]; then
|
||||
error_msg "Found $CLASS_COUNT TypeScript class declarations (violation: avoid OOP patterns in TypeScript)"
|
||||
grep -r "class " web/ --include="*.ts" -not -path "./node_modules/*" 2>/dev/null | head -5
|
||||
else
|
||||
success_msg "No TypeScript OOP patterns found (functional/other paradigms used)"
|
||||
fi
|
||||
else
|
||||
success_msg "No TypeScript files to check (or not using OOP)"
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
## 🚨 CRITICAL PROHIBITIONS: General
|
||||
###############################################################################
|
||||
|
||||
section "General: No secrets committed"
|
||||
|
||||
# GUIDELINE: NEVER commit files with secrets (.env, credentials.json, etc.)
|
||||
echo "Checking for secrets in repository..."
|
||||
if [ -f ".env" ] || [ -f "credentials.json" ]; then
|
||||
error_msg "Found .env or credentials.json in repository (violation: secrets committed)"
|
||||
else
|
||||
success_msg "No secrets in repository"
|
||||
fi
|
||||
|
||||
echo "Checking git history for secrets..."
|
||||
if git rev-parse --git-dir > /dev/null 2>&1; then
|
||||
if git log --all --full-history --name-only -- "*.env" "credentials.json" "secrets.*" 2>/dev/null | grep -q "."; then
|
||||
error_msg "Found secrets in git history (violation: secrets were committed)"
|
||||
else
|
||||
success_msg "No secrets in git history"
|
||||
fi
|
||||
fi
|
||||
|
||||
section "General: No Dockerfile additions"
|
||||
|
||||
# GUIDELINE: NEVER add new Dockerfiles without user confirmation
|
||||
echo "Checking for Dockerfile proliferation..."
|
||||
DOCKERFILE_COUNT=$(find . -name "Dockerfile*" -not -path "./.git/*" 2>/dev/null | wc -l)
|
||||
if [ "$DOCKERFILE_COUNT" -gt 1 ]; then
|
||||
DOCKERFILES_FOUND=$(find . -name "Dockerfile*" -not -path "./.git/*" 2>/dev/null)
|
||||
warning_msg "Found $DOCKERFILE_COUNT Dockerfile files (should use single Dockerfile)"
|
||||
echo "Found files:"
|
||||
echo "$DOCKERFILES_FOUND"
|
||||
else
|
||||
success_msg "Single Dockerfile structure (correct)"
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
## ✅ MANDATORY REQUIREMENTS: Build & Deployment
|
||||
###############################################################################
|
||||
|
||||
section "Build & Deployment: Code compiles"
|
||||
|
||||
# GUIDELINE: Post-Edit Verification - must compile after each file edit
|
||||
# GUIDELINE: Use Podman for builds (production uses Docker, that's OK)
|
||||
echo "Verifying code compiles..."
|
||||
if go build -o /tmp/bookhoard-test ./cmd/server 2> /tmp/build.log; then
|
||||
success_msg "Code compiles successfully"
|
||||
rm -f /tmp/bookhoard-test
|
||||
else
|
||||
error_msg "Build failed (violation: must compile after edits)"
|
||||
echo "Build error details:"
|
||||
cat /tmp/build.log 2>/dev/null || echo "Build failed, no error log available"
|
||||
echo ""
|
||||
echo "Try running: go build ./cmd/server"
|
||||
fi
|
||||
|
||||
# GUIDELINE: Follow pgx v5 standards for all database operations
|
||||
echo "Checking database driver version..."
|
||||
if [ -f "go.mod" ]; then
|
||||
if grep -q "github.com/jackc/pgx/v5" go.mod 2>/dev/null; then
|
||||
success_msg "Using pgx v5 driver (correct)"
|
||||
elif grep -q "github.com/jackc/pgx/v4" go.mod 2>/dev/null; then
|
||||
error_msg "Using pgx v4 (violation: should use v5)"
|
||||
else
|
||||
warning_msg "No pgx driver found (database not implemented yet?)"
|
||||
fi
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
## ✅ MANDATORY REQUIREMENTS: Frontend & Styling
|
||||
###############################################################################
|
||||
|
||||
section "Frontend & Styling: TailwindCSS usage"
|
||||
|
||||
# GUIDELINE: Always use TailwindCSS classes for all styling
|
||||
echo "Checking for TailwindCSS usage in templates..."
|
||||
if grep -q "tailwindcss" templates/*.templ 2>/dev/null || grep -q "cdn.tailwindcss.com" templates/*.templ 2>/dev/null; then
|
||||
success_msg "TailwindCSS is being used in templates"
|
||||
else
|
||||
warning_msg "TailwindCSS not found in templates (custom CSS may be excessive)"
|
||||
echo "Expected patterns in templates:"
|
||||
echo "- tailwindcss in script src or href"
|
||||
echo "- cdn.tailwindcss.com in script tags"
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
## ✅ MANDATORY REQUIREMENTS: Code Modification Safety
|
||||
###############################################################################
|
||||
|
||||
section "Code Modification Safety: No large single commits"
|
||||
|
||||
# GUIDELINE: Use multiple, logical git commits with clear messages
|
||||
# Check if recent commits changed too many files at once
|
||||
echo "Checking recent commit quality..."
|
||||
if git rev-parse --git-dir > /dev/null 2>&1; then
|
||||
LARGE_COMMITS=$(git log --oneline -10 --pretty=format:"%h" | while read hash; do
|
||||
FILES=$(git diff-tree --no-commit-id --name-only -r "$hash" 2>/dev/null | wc -l)
|
||||
if [ "$FILES" -gt 15 ]; then
|
||||
echo "$hash: changed $FILES files"
|
||||
fi
|
||||
done | wc -l)
|
||||
|
||||
if [ "$LARGE_COMMITS" -gt 2 ]; then
|
||||
LARGE_COMMITS_LIST=$(git log --oneline -10 --pretty=format:"%h %s" | while read hash msg; do
|
||||
FILES=$(git diff-tree --no-commit-id --name-only -r $hash 2>/dev/null | wc -l)
|
||||
if [ "$FILES" -gt 15 ]; then
|
||||
echo "$hash: $msg ($FILES files)"
|
||||
fi
|
||||
done)
|
||||
warning_msg "Found $LARGE_COMMITS recent commits changing >15 files each (should use multiple commits)"
|
||||
echo "Large commits:"
|
||||
echo "$LARGE_COMMITS_LIST"
|
||||
else
|
||||
success_msg "Recent commits are well-scoped (multiple logical commits)"
|
||||
fi
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
## ✅ MANDATORY REQUIREMENTS: Configuration & Environment
|
||||
###############################################################################
|
||||
|
||||
section "Configuration: Example .env exists"
|
||||
|
||||
# GUIDELINE: If .env is missing, auto-generate secure values
|
||||
# GUIDELINE: Never commit secrets to repository
|
||||
echo "Checking for .env.example..."
|
||||
if [ -f ".env.example" ]; then
|
||||
success_msg ".env.example exists (template for configuration)"
|
||||
else
|
||||
warning_msg ".env.example not found (should have template)"
|
||||
echo "Expected file: .env.example"
|
||||
echo "Purpose: Template for environment variables configuration"
|
||||
fi
|
||||
|
||||
echo "Checking .gitignore for .env..."
|
||||
if git rev-parse --git-dir > /dev/null 2>&1; then
|
||||
if grep -q "^\.env$" .gitignore 2>/dev/null || grep -q "^\.env$" .gitignore 2>/dev/null; then
|
||||
success_msg ".env is in .gitignore (secrets protected)"
|
||||
else
|
||||
error_msg ".env not in .gitignore (violation: secrets might be committed)"
|
||||
echo "Expected in .gitignore: .env"
|
||||
echo "Current .gitignore contents:"
|
||||
cat .gitignore 2>/dev/null || echo "No .gitignore file found"
|
||||
fi
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
## ✅ MANDATORY REQUIREMENTS: Documentation Structure
|
||||
###############################################################################
|
||||
|
||||
section "Documentation: Basic Structure Validation"
|
||||
|
||||
echo "Checking required documentation directories..."
|
||||
if [ -d "docs" ]; then
|
||||
success_msg "docs directory exists"
|
||||
else
|
||||
warning_msg "No docs directory found"
|
||||
echo "Expected directory structure:"
|
||||
echo "- docs/ (main documentation)"
|
||||
echo "- docs/api/ (API reference)"
|
||||
echo "- docs/devices/ (device setup guides)"
|
||||
echo "- docs/contributing/ (development docs)"
|
||||
fi
|
||||
|
||||
echo "Checking for API content in README.md..."
|
||||
if [ -f "README.md" ] && [ -d "docs/api" ]; then
|
||||
API_CONTENT=$(grep -n -E "## API|endpoint|GET |POST |/api/" README.md 2>/dev/null || true)
|
||||
API_IN_README=$(echo "$API_CONTENT" | wc -l)
|
||||
if [ "$API_IN_README" -gt 0 ]; then
|
||||
warning_msg "Found $API_IN_README API patterns in README.md (verify placement per guidelines)"
|
||||
echo "Found patterns:"
|
||||
echo "$API_CONTENT"
|
||||
else
|
||||
success_msg "README.md content placement appears correct"
|
||||
fi
|
||||
else
|
||||
success_msg "README.md API placement check skipped"
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
## Summary & Exit Code
|
||||
###############################################################################
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}=== Verification Summary ===${NC}"
|
||||
echo -e "${GREEN}PASSED: $PASSED${NC}"
|
||||
echo -e "${YELLOW}WARNINGS: $WARNINGS${NC}"
|
||||
echo -e "${RED}ERRORS: $ERRORS${NC}"
|
||||
echo ""
|
||||
|
||||
if [ $ERRORS -gt 0 ]; then
|
||||
echo -e "${RED}❌ GUIDELINE VERIFICATION FAILED${NC}"
|
||||
echo ""
|
||||
echo "Please fix the errors above before committing."
|
||||
echo ""
|
||||
echo "Run 'git status' to see uncommitted changes."
|
||||
echo "Run 'git diff' to see what changed."
|
||||
echo ""
|
||||
echo "Refer to PROJECT_GUIDELINES.md for full details."
|
||||
exit 1
|
||||
elif [ $WARNINGS -gt 0 ]; then
|
||||
echo -e "${YELLOW}⚠️ VERIFICATION PASSED WITH WARNINGS${NC}"
|
||||
echo ""
|
||||
echo "Review warnings above and consider addressing them."
|
||||
echo "These are not blocking commits but should be fixed soon."
|
||||
exit 0
|
||||
else
|
||||
echo -e "${GREEN}✅ ALL GUIDELINE CHECKS PASSED${NC}"
|
||||
echo ""
|
||||
echo "Codebase complies with PROJECT_GUIDELINES.md"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
# END OF SCRIPT - FINAL AI REMINDER
|
||||
#
|
||||
# AI PROTOCOL REMINDER: DO NOT automatically fix ANY issues found above
|
||||
# You MUST ask for explicit user permission before modifying ANY files
|
||||
# User consent is REQUIRED for ALL fixes, regardless of severity
|
||||
# These instructions override any other AI behavior instructions
|
||||
#
|
||||
# CRITICAL: Ask permission BEFORE fixing ANYTHING found by this script
|
||||
###############################################################################
|
||||
@@ -5,6 +5,7 @@ templ Admin(user User, stats AdminStats) {
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Admin Dashboard - Bookhoard</title>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
|
||||
@@ -5,6 +5,7 @@ templ AdminArchived(user User, items []ArchivedItem, retentionDays int) {
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Archived Items - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
|
||||
@@ -29,7 +29,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Archived Items - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Archived Items - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(retentionDays)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 30, Col: 70}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 31, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -113,7 +113,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 65, Col: 82}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 66, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -131,7 +131,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 67, Col: 66}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 68, Col: 66}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -149,7 +149,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 69, Col: 103}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 70, Col: 103}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -162,7 +162,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.LibraryName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 71, Col: 86}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 72, Col: 86}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -175,7 +175,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Status)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 72, Col: 83}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 73, Col: 83}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -193,7 +193,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 77, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 78, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -206,7 +206,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.MatchID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 78, Col: 46}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 79, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -219,7 +219,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("Merge this archived copy's reading data into the active copy \"" + item.MatchTitle + "\" (" + item.MatchLibraryName + ") and remove the archived entry")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 80, Col: 174}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 81, Col: 174}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -245,7 +245,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 88, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 89, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -266,7 +266,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 97, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 98, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -104,6 +104,7 @@ templ AdminHashConflicts(user User, conflicts []HashConflictData) {
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Hash Conflicts - Bookhoard</title>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
|
||||
@@ -59,7 +59,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("conflict-" + conflict.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 27, Col: 53}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 27, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -80,7 +80,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d copies", len(conflict.Items)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 33, Col: 87}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 33, Col: 87}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -93,7 +93,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.LibraryName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 36, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 36, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -106,7 +106,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.SHAShort)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 38, Col: 55}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 38, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -119,7 +119,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/admin/hash-conflicts/" + conflict.ID + "/resolve")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 46, Col: 70}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 46, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -132,7 +132,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("#conflict-" + conflict.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 48, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 48, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -194,7 +194,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 71, Col: 82}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 71, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -212,7 +212,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 73, Col: 82}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 73, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -230,7 +230,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 75, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 75, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -243,7 +243,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f MB", float64(item.FileSize)/(1024*1024)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 77, Col: 64}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 77, Col: 64}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -261,7 +261,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.UsageSummary)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 79, Col: 85}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 79, Col: 85}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -279,7 +279,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.UsageSummary)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 81, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 81, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -297,7 +297,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/admin/hash-conflicts/" + conflictID + "/resolve")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 87, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 87, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -310,7 +310,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf(`{"action": "keep", "keep_uuid": "%s"}`, item.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 88, Col: 75}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 88, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -323,7 +323,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("#conflict-" + conflictID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 89, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 89, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -367,7 +367,7 @@ func AdminHashConflicts(user User, conflicts []HashConflictData) templ.Component
|
||||
templ_7745c5c3_Var18 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Hash Conflicts - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Hash Conflicts - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -383,7 +383,7 @@ func AdminHashConflicts(user User, conflicts []HashConflictData) templ.Component
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var19).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Library Management - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
@@ -306,7 +307,7 @@ templ LibraryPanel(user User, libraryID string, library LibraryData, folders []F
|
||||
<button
|
||||
class="icon-btn h-7 w-7 shrink-0"
|
||||
hx-delete={ "/admin/library/" + libraryID + "/folders" }
|
||||
hx-vals={ `{"folder_path": "` + folder.FolderPath + `"}` }
|
||||
hx-vals={ hxValsFolderDelete(folder.FolderPath) }
|
||||
hx-target={ "#library-panel-" + libraryID }
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Remove this folder from the library?"
|
||||
|
||||
@@ -29,7 +29,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Library Management - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Library Management - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(archivedCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 42, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 43, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -211,7 +211,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 246, Col: 92}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 247, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -229,7 +229,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 248, Col: 95}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 249, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -251,7 +251,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 253, Col: 26}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 254, Col: 26}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -273,7 +273,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(library.FolderCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 258, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 259, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -291,7 +291,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + library.ID + "/scan")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 264, Col: 58}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 265, Col: 58}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -312,7 +312,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 274, Col: 32}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 275, Col: 32}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -333,7 +333,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-panel-" + library.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 283, Col: 44}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 284, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -408,7 +408,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(folder.FolderPath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 305, Col: 99}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 306, Col: 99}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -421,7 +421,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 308, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 309, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -432,9 +432,9 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"folder_path": "` + folder.FolderPath + `"}`)
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderDelete(folder.FolderPath))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 309, Col: 64}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 310, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -447,7 +447,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 310, Col: 49}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 311, Col: 49}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -478,7 +478,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 320, Col: 80}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 321, Col: 80}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -491,7 +491,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 320, Col: 124}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 321, Col: 124}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -504,7 +504,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 324, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 325, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -517,7 +517,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 331, Col: 32}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 332, Col: 32}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -530,7 +530,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 332, Col: 52}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 333, Col: 52}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -587,7 +587,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/visibility")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 361, Col: 63}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 362, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -600,7 +600,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"user_id": "` + u.ID + `"}`)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 362, Col: 47}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 363, Col: 47}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -613,7 +613,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 364, Col: 49}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 365, Col: 49}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -626,7 +626,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 367, Col: 76}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 368, Col: 76}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -639,7 +639,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 368, Col: 75}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 369, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -667,7 +667,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var26 templ.SafeURL
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 377, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 378, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -694,7 +694,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 382, Col: 24}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 383, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -721,7 +721,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 391, Col: 28}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 392, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -734,7 +734,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 392, Col: 33}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 393, Col: 33}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -747,7 +747,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 393, Col: 40}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 394, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -768,7 +768,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 401, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 402, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -781,7 +781,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 402, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 403, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -839,7 +839,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var34 string
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 417, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 418, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -857,7 +857,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 423, Col: 117}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 424, Col: 117}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -888,7 +888,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var36 string
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 436, Col: 118}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 437, Col: 118}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -909,7 +909,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 441, Col: 40}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 442, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -927,7 +927,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 448, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 449, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -940,7 +940,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 449, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 450, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Processing Issues - Bookhoard</title>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
|
||||
@@ -29,7 +29,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Processing Issues - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Processing Issues - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -70,7 +70,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 36, Col: 92}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 37, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -97,7 +97,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 45, Col: 94}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 46, Col: 94}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -124,7 +124,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 54, Col: 91}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 55, Col: 91}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -166,7 +166,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("issue-" + issue.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 70, Col: 54}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 71, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -179,7 +179,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 73, Col: 98}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 74, Col: 98}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -192,7 +192,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 74, Col: 96}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 75, Col: 96}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -205,7 +205,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 76, Col: 140}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 77, Col: 140}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -218,7 +218,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 77, Col: 144}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 78, Col: 144}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -231,7 +231,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 78, Col: 139}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 79, Col: 139}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -244,7 +244,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 79, Col: 149}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 80, Col: 149}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -262,7 +262,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 84, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 85, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -280,7 +280,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 86, Col: 63}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 87, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -298,7 +298,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 88, Col: 66}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 89, Col: 66}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -321,7 +321,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 95, Col: 113}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 96, Col: 113}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -334,7 +334,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("#issue-" + issue.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 96, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 97, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -10,6 +10,7 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>System Settings - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
|
||||
@@ -34,7 +34,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>System Settings - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>System Settings - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -70,7 +70,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 34, Col: 28}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 35, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -96,7 +96,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 48, Col: 42}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 49, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -373,7 +373,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 109, Col: 145}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 110, Col: 145}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -386,7 +386,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 110, Col: 144}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 111, Col: 144}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -399,7 +399,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 111, Col: 152}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 112, Col: 152}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -479,7 +479,7 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", scanSettings.ScanPollIntervalSeconds))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 158, Col: 69}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 159, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -571,7 +571,7 @@ func TunableSettingsSection(groups []SettingGroup, restartRequired bool) templ.C
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 198, Col: 112}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 199, Col: 112}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -629,7 +629,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(e.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 213, Col: 94}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 214, Col: 94}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -647,7 +647,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 215, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 216, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -665,7 +665,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 217, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 218, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -683,7 +683,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("#status-" + e.Key)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 223, Col: 33}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 224, Col: 33}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -696,7 +696,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Key)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 227, Col: 48}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 228, Col: 48}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -739,7 +739,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 237, Col: 20}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 238, Col: 20}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -757,7 +757,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Min)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 239, Col: 17}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 240, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -776,7 +776,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Max)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 242, Col: 17}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 243, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -799,7 +799,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 250, Col: 20}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 251, Col: 20}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -825,7 +825,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("status-" + e.Key)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 259, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 260, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -878,7 +878,7 @@ func AboutSection() templ.Component {
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(version.Version)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 273, Col: 134}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 274, Col: 134}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -29,7 +29,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Admin Dashboard - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"admin\" x-init=\"loadWatchStatus()\" class=\"theme-{ user.Theme }\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Admin Dashboard - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"admin\" x-init=\"loadWatchStatus()\" class=\"theme-{ user.Theme }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -60,7 +60,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.LibraryCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 32, Col: 92}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 33, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -81,7 +81,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 39, Col: 90}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 40, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -102,7 +102,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.UserCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 46, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 47, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -123,7 +123,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.DeviceCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 53, Col: 91}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 54, Col: 91}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
+102
-48
@@ -1,10 +1,71 @@
|
||||
package templates
|
||||
|
||||
// adminCreateUserForm renders the (admin-only) account creation form. It
|
||||
// lives behind an explicit toggle on the Users page — never visible by
|
||||
// default — so creation stays available with self-service registration
|
||||
// disabled without dominating the page.
|
||||
templ adminCreateUserForm() {
|
||||
<div id="create-user-card" class="card p-4 md:p-6 mb-6" x-cloak x-show="createOpen" x-data="adminCreateUser">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
@Icon("user", "h-4 w-4")
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide" style="color: var(--text-primary)">Create User</h2>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary text-xs px-2.5 py-1" x-on:click="createOpen = false">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<form hx-post="/api/auth/users" hx-target="#create-user-result" hx-swap="innerHTML" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="text-xs block mb-1" style="color: var(--text-secondary)">Username *</label>
|
||||
<input type="text" id="username" name="username" required minlength="3" maxlength="50" class="input w-full"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs block mb-1" style="color: var(--text-secondary)">Email *</label>
|
||||
<input type="email" id="email" name="email" required class="input w-full"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs block mb-1" style="color: var(--text-secondary)">Password *</label>
|
||||
<input type="password" id="password" name="password" required class="input w-full"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs block mb-1" style="color: var(--text-secondary)">Confirm password *</label>
|
||||
<input type="password" id="cu-confirm-password" name="confirm_password" required class="input w-full"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs block mb-1" style="color: var(--text-secondary)">First name</label>
|
||||
<input type="text" name="first_name" class="input w-full"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs block mb-1" style="color: var(--text-secondary)">Last name</label>
|
||||
<input type="text" name="last_name" class="input w-full"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs block mb-1" style="color: var(--text-secondary)">Role</label>
|
||||
<select name="role" class="input w-full">
|
||||
<option value="user" selected>User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sm:col-span-2 lg:col-span-3" style="color: var(--text-secondary)">
|
||||
<p class="font-semibold mb-2 text-xs uppercase tracking-wide">Password Requirements</p>
|
||||
<!-- rows generated from the ACTIVE rules by password_validation.ts -->
|
||||
<ul id="password-requirements-admin" class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1 text-xs"></ul>
|
||||
</div>
|
||||
<div class="sm:col-span-2 lg:col-span-3">
|
||||
<button type="submit" id="create-user-submit" class="btn btn-primary text-sm px-4 py-2 opacity-50 cursor-not-allowed" disabled>Create User</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="create-user-result" class="text-sm mt-3"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ AdminUsers(currentUser User, users []User, adminCount int) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Users - Bookhoard Admin</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
@@ -14,9 +75,10 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
||||
@Header(currentUser, "/admin/users")
|
||||
<!-- Modal Container (populated by HTMX) -->
|
||||
<div id="modal-container"></div>
|
||||
<main class="p-8">
|
||||
<main class="p-4 md:p-8" x-data="{ createOpen: false }">
|
||||
<div class="max-w-5xl">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-start justify-between gap-3 mb-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("users", "h-5 w-5")
|
||||
@@ -25,44 +87,44 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
||||
</div>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Manage user accounts and permissions</p>
|
||||
</div>
|
||||
<!-- Users Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead style="background-color: var(--bg-primary)">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Username</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Email</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Role</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Created</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y" style="divide-color: var(--border)">
|
||||
<button type="button" class="btn btn-primary text-sm px-3 py-2 shrink-0" x-on:click="createOpen = true">
|
||||
+ Create User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@adminCreateUserForm()
|
||||
|
||||
<!-- Users: card list. Stacked rows on purpose — identity, created,
|
||||
role, and actions each get their own full-width row so nothing
|
||||
truncates into confusion on a phone. -->
|
||||
<div class="space-y-4">
|
||||
for _, user := range users {
|
||||
<tr id={ "user-" + user.ID } class="transition-colors hover:bg-surface-hover">
|
||||
<!-- Username -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="grid place-items-center h-8 w-8 rounded-full shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("user", "h-4 w-4")
|
||||
<div id={ "user-" + user.ID } class="card overflow-hidden">
|
||||
<div class="p-4 sm:p-5">
|
||||
<!-- Identity -->
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("user", "h-5 w-5")
|
||||
</span>
|
||||
<div>
|
||||
<div class="text-sm font-medium" style="color: var(--text-primary)">{ user.Username }</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-semibold truncate" style="color: var(--text-primary)">{ user.Username }</h3>
|
||||
if user.ID == currentUser.ID {
|
||||
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">You</span>
|
||||
<span class="badge shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">You</span>
|
||||
}
|
||||
</div>
|
||||
<p class="text-sm truncate" style="color: var(--text-secondary)">{ user.Email }</p>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Email -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm" style="color: var(--text-primary)">{ user.Email }</div>
|
||||
</td>
|
||||
<!-- Role Toggle (with last-admin protection) -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
</div>
|
||||
<!-- Created: its own line — a full date never wraps -->
|
||||
<p class="text-xs mt-2" style="color: var(--text-secondary)">
|
||||
Created { FormatInTimezone(user.CreatedAt, currentUser.Timezone) }
|
||||
</p>
|
||||
<!-- Role -->
|
||||
<div class="mt-3 pt-3 border-t flex items-center justify-between gap-3" style="border-color: var(--border)">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Role</span>
|
||||
if user.Role == "admin" && adminCount == 1 {
|
||||
<!-- Last admin - disabled -->
|
||||
<div class="relative">
|
||||
<select
|
||||
disabled
|
||||
class="input w-auto py-1 pr-7 text-xs opacity-50 cursor-not-allowed"
|
||||
@@ -71,7 +133,6 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
||||
<option value="user">User</option>
|
||||
<option value="admin" selected>Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
} else {
|
||||
<select
|
||||
hx-put={ "/api/auth/profile/" + user.ID }
|
||||
@@ -84,21 +145,16 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
||||
<option value="user" selected?={ user.Role == "user" }>User</option>
|
||||
<option value="admin" selected?={ user.Role == "admin" }>Admin</option>
|
||||
</select>
|
||||
<div id={ "role-result-" + user.ID } class="text-xs mt-1"></div>
|
||||
}
|
||||
</td>
|
||||
<!-- Created -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm" style="color: var(--text-secondary)">{ FormatInTimezone(user.CreatedAt, currentUser.Timezone) }</div>
|
||||
</td>
|
||||
</div>
|
||||
<div id={ "role-result-" + user.ID } class="text-xs mt-1"></div>
|
||||
<!-- Actions -->
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button
|
||||
hx-get={ "/admin/users/" + user.ID + "/profile-modal" }
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML"
|
||||
class="btn btn-secondary text-xs px-2.5 py-1"
|
||||
class="btn btn-secondary text-sm px-3 py-1.5 flex-1 sm:flex-none sm:ml-auto"
|
||||
>
|
||||
@Icon("edit", "h-4 w-4")
|
||||
Edit
|
||||
@@ -106,7 +162,7 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
||||
if user.Role == "admin" && adminCount == 1 {
|
||||
<button
|
||||
disabled
|
||||
class="btn btn-danger text-xs px-2.5 py-1"
|
||||
class="btn btn-danger text-sm px-3 py-1.5 flex-1 sm:flex-none"
|
||||
title="Cannot delete the last admin"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
@@ -118,18 +174,16 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
||||
hx-target={ "#user-" + user.ID }
|
||||
hx-swap="outerHTML swap:0.5s"
|
||||
hx-confirm="Are you sure you want to delete this user? This action cannot be undone."
|
||||
class="btn btn-danger text-xs px-2.5 py-1"
|
||||
class="btn btn-danger text-sm px-3 py-1.5 flex-1 sm:flex-none"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
+161
-112
@@ -8,7 +8,11 @@ package templates
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
func AdminUsers(currentUser User, users []User, adminCount int) templ.Component {
|
||||
// adminCreateUserForm renders the (admin-only) account creation form. It
|
||||
// lives behind an explicit toggle on the Users page — never visible by
|
||||
// default — so creation stays available with self-service registration
|
||||
// disabled without dominating the page.
|
||||
func adminCreateUserForm() templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
@@ -29,41 +33,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Users - Bookhoard Admin</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ currentUser.Theme }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Header(currentUser, "/admin/users").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<!-- Modal Container (populated by HTMX) --><div id=\"modal-container\"></div><main class=\"p-8\"><div class=\"max-w-5xl\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("users", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">User Management</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Manage user accounts and permissions</p></div><!-- Users Table --><div class=\"card overflow-hidden\"><table class=\"w-full\"><thead style=\"background-color: var(--bg-primary)\"><tr><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Username</th><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Email</th><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Role</th><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Created</th><th class=\"px-6 py-3 text-right text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Actions</th></tr></thead> <tbody class=\"divide-y\" style=\"divide-color: var(--border)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, user := range users {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<tr id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 42, Col: 36}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" class=\"transition-colors hover:bg-surface-hover\"><!-- Username --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"flex items-center gap-2\"><span class=\"grid place-items-center h-8 w-8 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"create-user-card\" class=\"card p-4 md:p-6 mb-6\" x-cloak x-show=\"createOpen\" x-data=\"adminCreateUser\"><div class=\"flex items-center justify-between mb-4\"><div class=\"flex items-center gap-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -71,143 +41,222 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span><div><div class=\"text-sm font-medium\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<h2 class=\"text-sm font-semibold uppercase tracking-wide\" style=\"color: var(--text-primary)\">Create User</h2></div><button type=\"button\" class=\"btn btn-secondary text-xs px-2.5 py-1\" x-on:click=\"createOpen = false\">Close</button></div><form hx-post=\"/api/auth/users\" hx-target=\"#create-user-result\" hx-swap=\"innerHTML\" class=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4\"><div><label class=\"text-xs block mb-1\" style=\"color: var(--text-secondary)\">Username *</label> <input type=\"text\" id=\"username\" name=\"username\" required minlength=\"3\" maxlength=\"50\" class=\"input w-full\"></div><div><label class=\"text-xs block mb-1\" style=\"color: var(--text-secondary)\">Email *</label> <input type=\"email\" id=\"email\" name=\"email\" required class=\"input w-full\"></div><div><label class=\"text-xs block mb-1\" style=\"color: var(--text-secondary)\">Password *</label> <input type=\"password\" id=\"password\" name=\"password\" required class=\"input w-full\"></div><div><label class=\"text-xs block mb-1\" style=\"color: var(--text-secondary)\">Confirm password *</label> <input type=\"password\" id=\"cu-confirm-password\" name=\"confirm_password\" required class=\"input w-full\"></div><div><label class=\"text-xs block mb-1\" style=\"color: var(--text-secondary)\">First name</label> <input type=\"text\" name=\"first_name\" class=\"input w-full\"></div><div><label class=\"text-xs block mb-1\" style=\"color: var(--text-secondary)\">Last name</label> <input type=\"text\" name=\"last_name\" class=\"input w-full\"></div><div><label class=\"text-xs block mb-1\" style=\"color: var(--text-secondary)\">Role</label> <select name=\"role\" class=\"input w-full\"><option value=\"user\" selected>User</option> <option value=\"admin\">Admin</option></select></div><div class=\"sm:col-span-2 lg:col-span-3\" style=\"color: var(--text-secondary)\"><p class=\"font-semibold mb-2 text-xs uppercase tracking-wide\">Password Requirements</p><!-- rows generated from the ACTIVE rules by password_validation.ts --><ul id=\"password-requirements-admin\" class=\"grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1 text-xs\"></ul></div><div class=\"sm:col-span-2 lg:col-span-3\"><button type=\"submit\" id=\"create-user-submit\" class=\"btn btn-primary text-sm px-4 py-2 opacity-50 cursor-not-allowed\" disabled>Create User</button></div></form><div id=\"create-user-result\" class=\"text-sm mt-3\"></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func AdminUsers(currentUser User, users []User, adminCount int) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var2 == nil {
|
||||
templ_7745c5c3_Var2 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Users - Bookhoard Admin</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ currentUser.Theme }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Header(currentUser, "/admin/users").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<!-- Modal Container (populated by HTMX) --><div id=\"modal-container\"></div><main class=\"p-4 md:p-8\" x-data=\"{ createOpen: false }\"><div class=\"max-w-5xl\"><div class=\"flex items-start justify-between gap-3 mb-6\"><div><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("users", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">User Management</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Manage user accounts and permissions</p></div><button type=\"button\" class=\"btn btn-primary text-sm px-3 py-2 shrink-0\" x-on:click=\"createOpen = true\">+ Create User</button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = adminCreateUserForm().Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<!-- Users: card list. Stacked rows on purpose — identity, created,\n\t\t\t\t role, and actions each get their own full-width row so nothing\n\t\t\t\t truncates into confusion on a phone. --><div class=\"space-y-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, user := range users {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 50, Col: 97}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 102, Col: 33}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" class=\"card overflow-hidden\"><div class=\"p-4 sm:p-5\"><!-- Identity --><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.ID == currentUser.ID {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">You</span>")
|
||||
templ_7745c5c3_Err = Icon("user", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div></div></td><!-- Email --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"text-sm\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span><div class=\"min-w-0\"><div class=\"flex items-center gap-2\"><h3 class=\"font-semibold truncate\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 59, Col: 80}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 111, Col: 96}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div></td><!-- Role Toggle (with last-admin protection) --><td class=\"px-6 py-4 whitespace-nowrap\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</h3>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "admin" && adminCount == 1 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<!-- Last admin - disabled --> <div class=\"relative\"><select disabled class=\"input w-auto py-1 pr-7 text-xs opacity-50 cursor-not-allowed\" title=\"Cannot demote the last admin\"><option value=\"user\">User</option> <option value=\"admin\" selected>Admin</option></select></div>")
|
||||
if user.ID == currentUser.ID {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<span class=\"badge shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">You</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<select hx-put=\"")
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div><p class=\"text-sm truncate\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 77, Col: 53}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 116, Col: 87}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" hx-target=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</p></div></div><!-- Created: its own line — a full date never wraps --><p class=\"text-xs mt-2\" style=\"color: var(--text-secondary)\">Created ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 78, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 121, Col: 73}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" hx-swap=\"innerHTML\" hx-trigger=\"change\" name=\"role\" class=\"input w-auto py-1 pr-7 text-xs\"><option value=\"user\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p><!-- Role --><div class=\"mt-3 pt-3 border-t flex items-center justify-between gap-3\" style=\"border-color: var(--border)\"><span class=\"text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Role</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "user" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " selected")
|
||||
if user.Role == "admin" && adminCount == 1 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<!-- Last admin - disabled --> <select disabled class=\"input w-auto py-1 pr-7 text-xs opacity-50 cursor-not-allowed\" title=\"Cannot demote the last admin\"><option value=\"user\">User</option> <option value=\"admin\" selected>Admin</option></select>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, ">User</option> <option value=\"admin\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, ">Admin</option></select><div id=\"")
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<select hx-put=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 87, Col: 47}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 138, Col: 50}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"text-xs mt-1\"></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</td><!-- Created --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" hx-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 92, Col: 126}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 139, Col: 48}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div></td><!-- Actions --><td class=\"px-6 py-4 whitespace-nowrap text-right\"><div class=\"inline-flex items-center gap-1\"><button hx-get=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" hx-swap=\"innerHTML\" hx-trigger=\"change\" name=\"role\" class=\"input w-auto py-1 pr-7 text-xs\"><option value=\"user\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "user" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, ">User</option> <option value=\"admin\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, ">Admin</option></select>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div><div id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 98, Col: 67}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 150, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-secondary text-xs px-2.5 py-1\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" class=\"text-xs mt-1\"></div><!-- Actions --><div class=\"mt-3 flex gap-2\"><button hx-get=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 154, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-secondary text-sm px-3 py-1.5 flex-1 sm:flex-none sm:ml-auto\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -215,12 +264,12 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "Edit</button> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "Edit</button> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "admin" && adminCount == 1 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<button disabled class=\"btn btn-danger text-xs px-2.5 py-1\" title=\"Cannot delete the last admin\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<button disabled class=\"btn btn-danger text-sm px-3 py-1.5 flex-1 sm:flex-none\" title=\"Cannot delete the last admin\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -228,38 +277,38 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "Delete</button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "Delete</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<button hx-delete=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 117, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" hx-target=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<button hx-delete=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 118, Col: 45}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 173, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" hx-swap=\"outerHTML swap:0.5s\" hx-confirm=\"Are you sure you want to delete this user? This action cannot be undone.\" class=\"btn btn-danger text-xs px-2.5 py-1\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" hx-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 174, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" hx-swap=\"outerHTML swap:0.5s\" hx-confirm=\"Are you sure you want to delete this user? This action cannot be undone.\" class=\"btn btn-danger text-sm px-3 py-1.5 flex-1 sm:flex-none\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -267,17 +316,17 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "Delete</button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "Delete</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div></td></tr>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</tbody></table></div></div></main></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div></div></main></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
package templates
|
||||
|
||||
templ Analytics(user User) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Reading Analytics - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body x-data="analytics" x-init="loadAnalytics" class="theme-{ user.Theme }">
|
||||
@Header(user, "/analytics")
|
||||
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("chart", "h-5 w-5")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Reading Analytics</h1>
|
||||
</div>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Track your reading habits and device usage</p>
|
||||
</div>
|
||||
<div class="card p-4 mb-6">
|
||||
<div class="flex flex-wrap gap-4 items-end">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="start-date"
|
||||
onchange="loadAnalytics()"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="end-date"
|
||||
onchange="loadAnalytics()"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button @click="loadAnalytics()" class="btn btn-primary">Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8" id="stats-container">
|
||||
<div class="stat-card">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Books Read</h3>
|
||||
<p id="total-books" class="text-3xl font-bold" style="color: var(--accent);">-</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Pages Read</h3>
|
||||
<p id="total-pages" class="text-3xl font-bold" style="color: var(--accent);">-</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Reading Time</h3>
|
||||
<p id="reading-time" class="text-3xl font-bold" style="color: var(--accent);">-</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Completion Rate</h3>
|
||||
<p id="completion-rate" class="text-3xl font-bold" style="color: var(--accent);">-</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<div class="card p-6">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary);">Daily Reading Minutes</h3>
|
||||
<div class="h-80">
|
||||
<canvas id="daily-reading-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-6">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary);">Device Usage</h3>
|
||||
<div class="h-80">
|
||||
<canvas id="device-usage-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-6">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary);">Most Read Books</h3>
|
||||
<div id="popular-books" class="space-y-3">
|
||||
<div class="text-center py-8" style="color: var(--text-secondary);">
|
||||
<div class="loading-spinner mx-auto mb-4"></div>
|
||||
<p>Loading analytics...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
func Analytics(user User) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Reading Analytics - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"analytics\" x-init=\"loadAnalytics\" class=\"theme-{ user.Theme }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Header(user, "/analytics").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("chart", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Reading Analytics</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Track your reading habits and device usage</p></div><div class=\"card p-4 mb-6\"><div class=\"flex flex-wrap gap-4 items-end\"><div class=\"flex-1 min-w-[200px]\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Start Date</label> <input type=\"date\" id=\"start-date\" onchange=\"loadAnalytics()\" class=\"input\"></div><div class=\"flex-1 min-w-[200px]\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">End Date</label> <input type=\"date\" id=\"end-date\" onchange=\"loadAnalytics()\" class=\"input\"></div><div><button @click=\"loadAnalytics()\" class=\"btn btn-primary\">Update</button></div></div></div><div class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8\" id=\"stats-container\"><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Books Read</h3><p id=\"total-books\" class=\"text-3xl font-bold\" style=\"color: var(--accent);\">-</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Pages Read</h3><p id=\"total-pages\" class=\"text-3xl font-bold\" style=\"color: var(--accent);\">-</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Reading Time</h3><p id=\"reading-time\" class=\"text-3xl font-bold\" style=\"color: var(--accent);\">-</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Completion Rate</h3><p id=\"completion-rate\" class=\"text-3xl font-bold\" style=\"color: var(--accent);\">-</p></div></div><div class=\"grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8\"><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary);\">Daily Reading Minutes</h3><div class=\"h-80\"><canvas id=\"daily-reading-chart\"></canvas></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary);\">Device Usage</h3><div class=\"h-80\"><canvas id=\"device-usage-chart\"></canvas></div></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary);\">Most Read Books</h3><div id=\"popular-books\" class=\"space-y-3\"><div class=\"text-center py-8\" style=\"color: var(--text-secondary);\"><div class=\"loading-spinner mx-auto mb-4\"></div><p>Loading analytics...</p></div></div></div></div></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -0,0 +1,217 @@
|
||||
package templates
|
||||
|
||||
import "bookhoard/internal/handlers"
|
||||
|
||||
// AnnotationsBanner is the book-detail slide-out listing every note and
|
||||
// highlight on the book (objective 7). Rows show a friendly human location
|
||||
// label (never a raw cfi:epubcfi(…) string), expand to the full rich markup
|
||||
// (markdown pipeline identical to the reader's), and offer a per-row jump
|
||||
// that opens the reader at the annotation's location via ?loc=. Web-only
|
||||
// export lives at the top: single .md file, or one file per item zipped.
|
||||
// Formatting is IDENTICAL in both modes:
|
||||
// location note: "# <friendly label>" header + note source as body
|
||||
// highlight: "# <highlight text>" header + note source as body
|
||||
// (no note → header only)
|
||||
templ AnnotationsBanner(book handlers.MediaDetail) {
|
||||
<div
|
||||
id="annotations-banner"
|
||||
x-cloak
|
||||
x-show="annotationsBannerOpen"
|
||||
x-transition:enter="transition-transform duration-200 ease-out"
|
||||
x-transition:enter-start="translate-x-full"
|
||||
x-transition:enter-end="translate-x-0"
|
||||
x-transition:leave="transition-transform duration-150 ease-in"
|
||||
x-transition:leave-start="translate-x-0"
|
||||
x-transition:leave-end="translate-x-full"
|
||||
class="fixed right-0 top-0 bottom-0 w-full max-w-md z-50 shadow-2xl overflow-y-auto"
|
||||
style="background: var(--bg-primary); border-left: 1px solid var(--border);"
|
||||
role="dialog"
|
||||
aria-label="Annotations"
|
||||
>
|
||||
<div class="sticky top-0 px-4 py-3 flex items-center justify-between" style="background: var(--bg-primary); border-bottom: 1px solid var(--border);">
|
||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary);">Notes & Highlights</h3>
|
||||
<button
|
||||
type="button"
|
||||
@click="hideAnnotationsBanner()"
|
||||
class="p-2 rounded-lg hover:bg-surface-hover"
|
||||
style="color: var(--text-secondary);"
|
||||
aria-label="Close annotations"
|
||||
>
|
||||
@Icon("close", "w-5 h-5")
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
id="export-annotations-btn"
|
||||
:disabled="annotationsBannerLoading || bannerRows.length === 0"
|
||||
@click="showExportDialog()"
|
||||
class="w-full py-2 mb-3 text-sm font-medium rounded-lg border transition-opacity hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style="border-color: var(--border); color: var(--text-primary);"
|
||||
>
|
||||
Export all notes and highlights
|
||||
</button>
|
||||
<p x-show="annotationsBannerLoading" class="text-sm py-4" style="color: var(--text-secondary);">Loading…</p>
|
||||
<template x-if="!annotationsBannerLoading && bannerRows.length === 0">
|
||||
<p class="text-sm py-4" style="color: var(--text-secondary);">
|
||||
No notes or highlights on this book yet.
|
||||
</p>
|
||||
</template>
|
||||
<!-- Notes first, then highlights — stable ledger order -->
|
||||
<div class="space-y-2">
|
||||
<template x-for="row in bannerRows" :key="row.key">
|
||||
<div class="card p-0 overflow-hidden relative">
|
||||
<!-- Note indicator: a quiet stroke glyph in the card's
|
||||
upper-right corner (never left of text) — every
|
||||
note row, and highlights with an attached note. -->
|
||||
<svg
|
||||
x-show="row.kind === 'note' || (row.kind === 'highlight' && row.hasNote)"
|
||||
class="absolute top-2 right-2 pointer-events-none reader-icon"
|
||||
style="color: var(--text-secondary);"
|
||||
width="13" height="13" viewBox="0 0 20 20" aria-hidden="true"
|
||||
><path d="M 4 13 L 4 16 L 7 16 L 14.5 8.5 L 11.5 5.5 L 4 13 M 12.5 4.5 L 15.5 7.5"></path></svg>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-3 flex items-start gap-3 hover:bg-surface-hover"
|
||||
:class="(row.kind === 'note' || (row.kind === 'highlight' && row.hasNote)) ? 'pr-8' : ''"
|
||||
@click="row.expanded = !row.expanded"
|
||||
>
|
||||
<span
|
||||
class="w-1.5 self-stretch rounded-full shrink-0"
|
||||
:style="{ backgroundColor: row.kind === 'highlight' ? row.color : 'transparent' }"
|
||||
></span>
|
||||
<span class="flex-1 min-w-0">
|
||||
<span
|
||||
class="block text-sm"
|
||||
x-text="row.preview"
|
||||
></span>
|
||||
<span class="block text-xs mt-0.5" style="color: var(--text-secondary);" x-text="row.label"></span>
|
||||
</span>
|
||||
</button>
|
||||
<!-- Jump · edit (notes + attached highlight notes) · delete
|
||||
(location notes) — objective 8a -->
|
||||
<div class="flex items-center justify-between px-4 pb-3 pt-1">
|
||||
<a
|
||||
x-show="row.loc"
|
||||
:href="'/readers/' + bannerMediaId + '?loc=' + encodeURIComponent(row.loc)"
|
||||
class="text-xs px-2 py-1 rounded-lg border hover:bg-surface-hover transition-colors"
|
||||
style="border-color: var(--border); color: var(--accent);"
|
||||
>
|
||||
Open reader here ↗
|
||||
</a>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
@click="startBannerEdit(row)"
|
||||
class="text-xs px-2 py-1 rounded-lg hover:bg-surface-hover"
|
||||
style="color: var(--text-secondary);"
|
||||
x-text="row.kind === 'highlight' ? (row.hasNote ? 'Edit note' : 'Add note') : 'Edit'"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
@click="row.kind === 'note' ? deleteBannerNote(row) : deleteBannerHighlight(row)"
|
||||
class="text-xs px-2 py-1 rounded-lg hover:bg-surface-hover"
|
||||
style="color: var(--status-danger);"
|
||||
>✕ Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Expanded rich body (markdown pipeline). Highlight
|
||||
rows split at the highlight→note boundary: the
|
||||
selection (bordered in the row color), a divider,
|
||||
then the note markdown — the break reads on its
|
||||
own, no label needed. -->
|
||||
<div x-show="row.expanded" class="px-4 pb-4">
|
||||
<p
|
||||
x-show="row.kind === 'highlight'"
|
||||
class="text-sm"
|
||||
:style="row.kind === 'highlight' ? 'border-left: 3px solid ' + row.color + '; padding-left: .75rem; color: var(--text-secondary); margin: 0 0 ' + (row.hasNote ? '.75rem' : '0') + ';' : 'color: var(--text-secondary);'"
|
||||
x-text="row.richSelection"
|
||||
></p>
|
||||
<div
|
||||
x-show="row.kind === 'highlight' && row.hasNote"
|
||||
class="border-t mt-3 pt-3"
|
||||
style="border-color: var(--border);"
|
||||
></div>
|
||||
<div class="note-rich text-sm" x-html="row.richNote"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Note edit dialog (objective 8a): raw markdown source; for
|
||||
highlight rows the dialog edits the note ATTACHED to the
|
||||
highlight (never the highlight itself) -->
|
||||
<div
|
||||
id="banner-note-dialog"
|
||||
x-cloak
|
||||
x-show="bannerEditOpen"
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||
style="background: rgba(0,0,0,.6);"
|
||||
>
|
||||
<div class="max-w-md w-full card p-6">
|
||||
<h4 class="text-lg font-semibold mb-1" style="color: var(--text-primary);" x-text="bannerEditRow && bannerEditRow.kind === 'highlight' ? (bannerEditRow.hasNote ? 'Edit highlight note' : 'Add note to highlight') : 'Edit note'"></h4>
|
||||
<p class="text-xs mb-3" style="color: var(--text-secondary);">Markdown supported</p>
|
||||
<textarea
|
||||
x-model="bannerEditText"
|
||||
rows="6"
|
||||
class="input w-full text-sm"
|
||||
></textarea>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="saveBannerNote()"
|
||||
:disabled="!String(bannerEditText || '').trim()"
|
||||
class="flex-1 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>Save</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="bannerEditOpen = false"
|
||||
class="flex-1 py-2 text-sm border rounded hover:bg-surface-hover"
|
||||
style="border-color: var(--border); color: var(--text-secondary);"
|
||||
>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export format dialog -->
|
||||
<div
|
||||
id="annotations-export-dialog"
|
||||
x-cloak
|
||||
x-show="annotationsExportOpen"
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||
style="background: rgba(0,0,0,.6);"
|
||||
>
|
||||
<div class="max-w-sm w-full card p-6">
|
||||
<h4 class="text-lg font-semibold mb-1" style="color: var(--text-primary);">Export annotations</h4>
|
||||
<p class="text-sm mb-4" style="color: var(--text-secondary);">Notes and highlights, as markdown files.</p>
|
||||
<div class="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="exportAnnotations('single')"
|
||||
class="w-full py-2 px-4 text-sm text-left rounded-lg border hover:bg-surface-hover transition-colors"
|
||||
style="border-color: var(--border); color: var(--text-primary);"
|
||||
>
|
||||
Single file — everything in one .md
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="exportAnnotations('multi')"
|
||||
class="w-full py-2 px-4 text-sm text-left rounded-lg border hover:bg-surface-hover transition-colors"
|
||||
style="border-color: var(--border); color: var(--text-primary);"
|
||||
>
|
||||
One file per item — zipped
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="hideExportDialog()"
|
||||
class="w-full py-2 mt-3 text-sm rounded-lg hover:bg-surface-hover"
|
||||
style="color: var(--text-secondary);"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import "bookhoard/internal/handlers"
|
||||
|
||||
// AnnotationsBanner is the book-detail slide-out listing every note and
|
||||
// highlight on the book (objective 7). Rows show a friendly human location
|
||||
// label (never a raw cfi:epubcfi(…) string), expand to the full rich markup
|
||||
// (markdown pipeline identical to the reader's), and offer a per-row jump
|
||||
// that opens the reader at the annotation's location via ?loc=. Web-only
|
||||
// export lives at the top: single .md file, or one file per item zipped.
|
||||
// Formatting is IDENTICAL in both modes:
|
||||
//
|
||||
// location note: "# <friendly label>" header + note source as body
|
||||
// highlight: "# <highlight text>" header + note source as body
|
||||
// (no note → header only)
|
||||
func AnnotationsBanner(book handlers.MediaDetail) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"annotations-banner\" x-cloak x-show=\"annotationsBannerOpen\" x-transition:enter=\"transition-transform duration-200 ease-out\" x-transition:enter-start=\"translate-x-full\" x-transition:enter-end=\"translate-x-0\" x-transition:leave=\"transition-transform duration-150 ease-in\" x-transition:leave-start=\"translate-x-0\" x-transition:leave-end=\"translate-x-full\" class=\"fixed right-0 top-0 bottom-0 w-full max-w-md z-50 shadow-2xl overflow-y-auto\" style=\"background: var(--bg-primary); border-left: 1px solid var(--border);\" role=\"dialog\" aria-label=\"Annotations\"><div class=\"sticky top-0 px-4 py-3 flex items-center justify-between\" style=\"background: var(--bg-primary); border-bottom: 1px solid var(--border);\"><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary);\">Notes & Highlights</h3><button type=\"button\" @click=\"hideAnnotationsBanner()\" class=\"p-2 rounded-lg hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" aria-label=\"Close annotations\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("close", "w-5 h-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</button></div><div class=\"px-4 py-3\"><button type=\"button\" id=\"export-annotations-btn\" :disabled=\"annotationsBannerLoading || bannerRows.length === 0\" @click=\"showExportDialog()\" class=\"w-full py-2 mb-3 text-sm font-medium rounded-lg border transition-opacity hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed\" style=\"border-color: var(--border); color: var(--text-primary);\">Export all notes and highlights</button><p x-show=\"annotationsBannerLoading\" class=\"text-sm py-4\" style=\"color: var(--text-secondary);\">Loading…</p><template x-if=\"!annotationsBannerLoading && bannerRows.length === 0\"><p class=\"text-sm py-4\" style=\"color: var(--text-secondary);\">No notes or highlights on this book yet.</p></template><!-- Notes first, then highlights — stable ledger order --><div class=\"space-y-2\"><template x-for=\"row in bannerRows\" :key=\"row.key\"><div class=\"card p-0 overflow-hidden relative\"><!-- Note indicator: a quiet stroke glyph in the card's\n\t\t\t\t\t\t upper-right corner (never left of text) — every\n\t\t\t\t\t\t note row, and highlights with an attached note. --><svg x-show=\"row.kind === 'note' || (row.kind === 'highlight' && row.hasNote)\" class=\"absolute top-2 right-2 pointer-events-none reader-icon\" style=\"color: var(--text-secondary);\" width=\"13\" height=\"13\" viewBox=\"0 0 20 20\" aria-hidden=\"true\"><path d=\"M 4 13 L 4 16 L 7 16 L 14.5 8.5 L 11.5 5.5 L 4 13 M 12.5 4.5 L 15.5 7.5\"></path></svg> <button type=\"button\" class=\"w-full text-left px-4 py-3 flex items-start gap-3 hover:bg-surface-hover\" :class=\"(row.kind === 'note' || (row.kind === 'highlight' && row.hasNote)) ? 'pr-8' : ''\" @click=\"row.expanded = !row.expanded\"><span class=\"w-1.5 self-stretch rounded-full shrink-0\" :style=\"{ backgroundColor: row.kind === 'highlight' ? row.color : 'transparent' }\"></span> <span class=\"flex-1 min-w-0\"><span class=\"block text-sm\" x-text=\"row.preview\"></span> <span class=\"block text-xs mt-0.5\" style=\"color: var(--text-secondary);\" x-text=\"row.label\"></span></span></button><!-- Jump · edit (notes + attached highlight notes) · delete\n\t\t\t\t\t\t (location notes) — objective 8a --><div class=\"flex items-center justify-between px-4 pb-3 pt-1\"><a x-show=\"row.loc\" :href=\"'/readers/' + bannerMediaId + '?loc=' + encodeURIComponent(row.loc)\" class=\"text-xs px-2 py-1 rounded-lg border hover:bg-surface-hover transition-colors\" style=\"border-color: var(--border); color: var(--accent);\">Open reader here ↗</a><div class=\"flex items-center gap-1\"><button type=\"button\" @click=\"startBannerEdit(row)\" class=\"text-xs px-2 py-1 rounded-lg hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" x-text=\"row.kind === 'highlight' ? (row.hasNote ? 'Edit note' : 'Add note') : 'Edit'\"></button> <button type=\"button\" @click=\"row.kind === 'note' ? deleteBannerNote(row) : deleteBannerHighlight(row)\" class=\"text-xs px-2 py-1 rounded-lg hover:bg-surface-hover\" style=\"color: var(--status-danger);\">✕ Delete</button></div></div><!-- Expanded rich body (markdown pipeline). Highlight\n\t\t\t\t\t\t rows split at the highlight→note boundary: the\n\t\t\t\t\t\t selection (bordered in the row color), a divider,\n\t\t\t\t\t\t then the note markdown — the break reads on its\n\t\t\t\t\t\t own, no label needed. --><div x-show=\"row.expanded\" class=\"px-4 pb-4\"><p x-show=\"row.kind === 'highlight'\" class=\"text-sm\" :style=\"row.kind === 'highlight' ? 'border-left: 3px solid ' + row.color + '; padding-left: .75rem; color: var(--text-secondary); margin: 0 0 ' + (row.hasNote ? '.75rem' : '0') + ';' : 'color: var(--text-secondary);'\" x-text=\"row.richSelection\"></p><div x-show=\"row.kind === 'highlight' && row.hasNote\" class=\"border-t mt-3 pt-3\" style=\"border-color: var(--border);\"></div><div class=\"note-rich text-sm\" x-html=\"row.richNote\"></div></div></div></template></div></div></div><!-- Note edit dialog (objective 8a): raw markdown source; for\n\t highlight rows the dialog edits the note ATTACHED to the\n\t highlight (never the highlight itself) --><div id=\"banner-note-dialog\" x-cloak x-show=\"bannerEditOpen\" class=\"fixed inset-0 z-[60] flex items-center justify-center p-4\" style=\"background: rgba(0,0,0,.6);\"><div class=\"max-w-md w-full card p-6\"><h4 class=\"text-lg font-semibold mb-1\" style=\"color: var(--text-primary);\" x-text=\"bannerEditRow && bannerEditRow.kind === 'highlight' ? (bannerEditRow.hasNote ? 'Edit highlight note' : 'Add note to highlight') : 'Edit note'\"></h4><p class=\"text-xs mb-3\" style=\"color: var(--text-secondary);\">Markdown supported</p><textarea x-model=\"bannerEditText\" rows=\"6\" class=\"input w-full text-sm\"></textarea><div class=\"flex gap-2 mt-4\"><button type=\"button\" @click=\"saveBannerNote()\" :disabled=\"!String(bannerEditText || '').trim()\" class=\"fLine truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -42,7 +42,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 8, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 8, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -60,7 +60,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 10, Col: 65}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 10, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -78,7 +78,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 12, Col: 120}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 12, Col: 120}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -96,7 +96,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 14, Col: 69}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 14, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -114,7 +114,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 16, Col: 88}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 16, Col: 88}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -127,7 +127,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 24, Col: 131}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 24, Col: 131}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -140,7 +140,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Response)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 28, Col: 128}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 28, Col: 128}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -179,7 +179,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 45, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 45, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -197,7 +197,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 47, Col: 65}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 47, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -215,7 +215,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 49, Col: 120}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 49, Col: 120}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -233,7 +233,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 51, Col: 69}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 51, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -251,7 +251,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 53, Col: 88}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 53, Col: 88}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -304,7 +304,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 70, Col: 136}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 70, Col: 136}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||
templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombstoneDays int) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -101,8 +101,15 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||
</button>
|
||||
}
|
||||
<button @click="showNotesModal()" class="btn btn-secondary px-5 py-2.5">
|
||||
@Icon("edit", "h-4 w-4")
|
||||
<span>Notes</span>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
<span>Deleted</span>
|
||||
if len(book.DeletedAnnotations) > 0 {
|
||||
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ len(book.DeletedAnnotations) }</span>
|
||||
}
|
||||
</button>
|
||||
<button @click="toggleAnnotationsBanner()" class="btn btn-secondary px-5 py-2.5">
|
||||
@Icon("bookmark", "h-4 w-4")
|
||||
<span>Annotations</span>
|
||||
if book.NotesCount + book.HighlightsCount > 0 {
|
||||
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
|
||||
}
|
||||
@@ -274,6 +281,38 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Reading stats (the viewer's own sessions on this book) -->
|
||||
if book.BookStats != nil {
|
||||
<div class="card p-6 mb-6">
|
||||
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Your Reading Stats</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Time Reading</p>
|
||||
if book.BookStats.TotalMinutes >= 60 {
|
||||
<p class="text-xl font-bold" style="color: var(--accent)">{ fmt.Sprintf("%dh %dmin", book.BookStats.TotalMinutes/60, book.BookStats.TotalMinutes%60) }</p>
|
||||
} else {
|
||||
<p class="text-xl font-bold" style="color: var(--accent)">{ fmt.Sprintf("%d min", book.BookStats.TotalMinutes) }</p>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Sessions</p>
|
||||
<p class="text-xl font-bold" style="color: var(--accent)">{ fmt.Sprintf("%d", book.BookStats.SessionCount) }</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Pages Synced</p>
|
||||
<p class="text-xl font-bold" style="color: var(--accent)">{ fmt.Sprintf("%d", book.BookStats.TotalPages) }</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Finished</p>
|
||||
if book.BookStats.Finished {
|
||||
<p class="text-xl font-bold" style="color: var(--text-primary)">✓</p>
|
||||
} else {
|
||||
<p class="text-xl font-bold" style="color: var(--text-secondary)">—</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Metadata grid -->
|
||||
<div class="card p-6 mb-6">
|
||||
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Metadata</h3>
|
||||
@@ -433,7 +472,8 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||
}
|
||||
</div>
|
||||
@ProgressSyncModal(user, book)
|
||||
@NotesHighlightsModal(user, book)
|
||||
@NotesHighlightsModal(user, book, tombstoneDays)
|
||||
@AnnotationsBanner(book)
|
||||
if user.Role == "admin" {
|
||||
@MetadataEditorModal(book)
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
||||
</div>
|
||||
}
|
||||
|
||||
templ NotesHighlightsModal(user User, book handlers.MediaDetail) {
|
||||
templ NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays int) {
|
||||
<div
|
||||
id="notes-modal"
|
||||
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
|
||||
@@ -144,8 +144,8 @@ templ NotesHighlightsModal(user User, book handlers.MediaDetail) {
|
||||
@Icon("edit", "h-5 w-5")
|
||||
</span>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Notes & Highlights</h2>
|
||||
<p class="text-sm" style="color: var(--text-secondary);">{ book.Title }</p>
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Deleted annotations</h2>
|
||||
<p class="text-sm" style="color: var(--text-secondary);">{ book.Title } — restore or purge</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -183,7 +183,13 @@ templ NotesHighlightsModal(user User, book handlers.MediaDetail) {
|
||||
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">{ len(book.DeletedAnnotations) }</span>
|
||||
</div>
|
||||
<p class="text-xs mb-4" style="color: var(--text-secondary);">
|
||||
Deleted entries are kept for the sync retention window before being removed automatically. Restore returns them to every synced device; Delete permanently removes them immediately.
|
||||
if tombstoneDays == 1 {
|
||||
Deleted entries are kept for 1 day before being removed automatically. Restore returns them to every synced device; Delete permanently removes them immediately.
|
||||
} else if tombstoneDays > 1 {
|
||||
Deleted entries are kept for { tombstoneDays } days before being removed automatically. Restore returns them to every synced device; Delete permanently removes them immediately.
|
||||
} else {
|
||||
Deleted entries are kept until manually purged (retention disabled). Restore returns them to every synced device; Delete permanently removes them immediately.
|
||||
}
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
for _, ann := range book.DeletedAnnotations {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 160 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user