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 | ||
|
|
b9f797789f | ||
|
|
6aaefe210a | ||
|
|
fb21d5249c | ||
|
|
b03ed9a2f2 | ||
|
|
72839921f2 | ||
|
|
25981fc762 | ||
|
|
f2629942e4 |
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
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"bookhoard/internal/router"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/sync"
|
||||
"bookhoard/templates"
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
@@ -56,6 +57,20 @@ func main() {
|
||||
if err := registry.Load(ctx); err != nil {
|
||||
log.Printf("⚠️ Could not load system settings (using defaults): %v", err)
|
||||
}
|
||||
|
||||
// Public signup surfaces follow the registration_enabled flag, with the
|
||||
// first-user exception: an unconfigured server (zero admins) keeps its
|
||||
// signup links and registration route reachable for bootstrapping.
|
||||
templates.SetSignupVisibility(func() bool {
|
||||
if registry.RegistrationEnabled() {
|
||||
return true
|
||||
}
|
||||
count, err := queries.CountAdmins(context.Background())
|
||||
if err != nil {
|
||||
return true // fail open, matching setupstatus
|
||||
}
|
||||
return count == 0
|
||||
})
|
||||
// Wire the registry into the package-level password validator so live
|
||||
// rule changes apply to the echo struct-tag validator and ValidatePassword.
|
||||
middleware.SetDefaultPasswordSettings(registry)
|
||||
@@ -140,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")
|
||||
@@ -211,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)
|
||||
})
|
||||
}
|
||||
@@ -384,12 +384,12 @@ func TestCollectionSearchLibraryFilter(t *testing.T) {
|
||||
book2ID := createTestMediaItemIDInLibrary(t, client, setup, lib2Resp["id"].(string), "Harry Potter 2")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
libraryID string
|
||||
expectedCount int
|
||||
name string
|
||||
query string
|
||||
libraryID string
|
||||
expectedCount int
|
||||
expectedStatus int
|
||||
shouldContain string // Comma-separated list of book IDs to check
|
||||
shouldContain string // Comma-separated list of book IDs to check
|
||||
}{
|
||||
{
|
||||
name: "no filter - both test books present",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -77,21 +77,21 @@ type DeviceTestData struct {
|
||||
|
||||
// TestServerSetup manages the lifecycle of a test server with proper resource cleanup
|
||||
type TestServerSetup struct {
|
||||
Server *httptest.Server
|
||||
DB *database.Queries
|
||||
DBPool *pgxpool.Pool
|
||||
Config *config.Config
|
||||
ConnManager *wsync.ConnectionManager
|
||||
QueueProcessor *wsync.SyncQueueProcessor
|
||||
ProgressService *wsync.ProgressService
|
||||
Server *httptest.Server
|
||||
DB *database.Queries
|
||||
DBPool *pgxpool.Pool
|
||||
Config *config.Config
|
||||
ConnManager *wsync.ConnectionManager
|
||||
QueueProcessor *wsync.SyncQueueProcessor
|
||||
ProgressService *wsync.ProgressService
|
||||
AnnotationService *wsync.AnnotationService
|
||||
CleanupCancel context.CancelFunc
|
||||
QueueCtx context.Context
|
||||
QueueCancel context.CancelFunc
|
||||
Token string
|
||||
RegularToken string
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
CleanupCancel context.CancelFunc
|
||||
QueueCtx context.Context
|
||||
QueueCancel context.CancelFunc
|
||||
Token string
|
||||
RegularToken string
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
// Close cleans up all resources in the correct order
|
||||
@@ -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)
|
||||
@@ -515,39 +542,45 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
|
||||
// Setup routes using router package
|
||||
routerConfig := &router.Config{
|
||||
Echo: e,
|
||||
Queries: queries,
|
||||
Cfg: cfg,
|
||||
DBPool: dbPool,
|
||||
AuthHandler: authHandler,
|
||||
LibraryHandler: libraryHandler,
|
||||
DeviceHandler: deviceHandler,
|
||||
MediaHandler: mediaHandler,
|
||||
MatchingHandler: matchingHandler,
|
||||
KOReaderHandler: koreaderHandler,
|
||||
WSHandler: wsHandler,
|
||||
ConflictHandler: conflictHandler,
|
||||
AnalyticsHandler: analyticsHandler,
|
||||
QueueHandler: queueHandler,
|
||||
SystemSettingsHandler: systemSettingsHandler,
|
||||
Echo: e,
|
||||
Queries: queries,
|
||||
Cfg: cfg,
|
||||
DBPool: dbPool,
|
||||
AuthHandler: authHandler,
|
||||
LibraryHandler: libraryHandler,
|
||||
DeviceHandler: deviceHandler,
|
||||
MediaHandler: mediaHandler,
|
||||
MatchingHandler: matchingHandler,
|
||||
KOReaderHandler: koreaderHandler,
|
||||
WSHandler: wsHandler,
|
||||
ConflictHandler: conflictHandler,
|
||||
AnalyticsHandler: analyticsHandler,
|
||||
StatsHandler: statsHandler,
|
||||
StatisticsService: statisticsService,
|
||||
QueueHandler: queueHandler,
|
||||
SystemSettingsHandler: systemSettingsHandler,
|
||||
ProcessingIssuesHandler: processingIssuesHandler,
|
||||
CollectionHandler: collectionHandler,
|
||||
FiltersHandler: filtersHandler,
|
||||
DashboardHandler: dashboardHandler,
|
||||
DashboardService: dashboardService,
|
||||
SeriesHandler: seriesHandler,
|
||||
OPDSHandler: opdsHandler,
|
||||
JobsHandler: jobsHandler,
|
||||
ConnManager: connManager,
|
||||
QueueProcessor: queueProcessor,
|
||||
ProgressService: progressService,
|
||||
AnnotationService: annotationService,
|
||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||
LoginTracker: loginAttemptTracker,
|
||||
CollectionHandler: collectionHandler,
|
||||
FiltersHandler: filtersHandler,
|
||||
DashboardHandler: dashboardHandler,
|
||||
DashboardService: dashboardService,
|
||||
SeriesHandler: seriesHandler,
|
||||
OPDSHandler: opdsHandler,
|
||||
JobsHandler: jobsHandler,
|
||||
ConnManager: connManager,
|
||||
QueueProcessor: queueProcessor,
|
||||
ProgressService: progressService,
|
||||
AnnotationService: annotationService,
|
||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||
LoginTracker: loginAttemptTracker,
|
||||
// RegisterRoutes dereferences Settings for the auth rate limit;
|
||||
// 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.
|
||||
createDefaultCollectionsForUser(t, queries, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||
}
|
||||
// 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,6 +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', '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'),
|
||||
@@ -1134,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
|
||||
@@ -1159,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(),
|
||||
|
||||
+2
-1
@@ -15,7 +15,8 @@ services:
|
||||
# Make other volumes as needed
|
||||
- ./uploads:/app/uploads
|
||||
ports:
|
||||
- "${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||
- "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||
- "[::1]:${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 30s
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -32,8 +32,13 @@ The Bookhoard scanner provides comprehensive library management for ebooks, comi
|
||||
| PDF | `.pdf` |
|
||||
| Kindle | `.mobi` |
|
||||
| Text | `.txt`, `.rtf` |
|
||||
| Document | `.doc`, `.docx` |
|
||||
| Other | `.lit`, `.fb2`, `.pdb` |
|
||||
| Document | `.docx` |
|
||||
| Other | `.fb2`, `.pdb` |
|
||||
|
||||
DOC (Word 97-2003 binary) and LIT (Microsoft Reader) are deliberately
|
||||
unsupported: no credible JavaScript parser exists for either, and
|
||||
compromising on fidelity is against the project's format goals. Files
|
||||
with these extensions are not scanned.
|
||||
|
||||
### Comics
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Initiate a one-time scan of a library for ebooks, manga, or comics.
|
||||
|
||||
The scanner automatically detects and processes files based on the library type:
|
||||
|
||||
**Ebooks:** .epub, .pdf, .mobi, .txt, .rtf, .doc, .docx, .lit, .fb2, .pdb
|
||||
**Ebooks:** .epub, .pdf, .mobi, .txt, .rtf, .docx, .fb2, .pdb
|
||||
|
||||
**Comics:** .cbz, .cbr, .cb7, .cbt, .pdf
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1198
-61
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
|
||||
|
||||
@@ -65,6 +65,7 @@ var SettingDefaults = []SettingDefault{
|
||||
{Key: "password_require_lower", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one lowercase letter (a-z)"},
|
||||
{Key: "password_require_number", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one number (0-9)"},
|
||||
{Key: "password_require_special", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one special character"},
|
||||
{Key: "registration_enabled", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Public Registration", Description: "Allow users to create their own accounts (admins can always create accounts)"},
|
||||
{Key: "opds_default_page_size", Value: "50", Type: SettingTypeInt, Min: "1", Max: "500", Category: "api", Group: "OPDS Catalog", Description: "Default OPDS page size"},
|
||||
{Key: "opds_max_page_size", Value: "200", Type: SettingTypeInt, Min: "1", Max: "1000", Category: "api", Group: "OPDS Catalog", Description: "Maximum OPDS page size"},
|
||||
{Key: "device_rate_sync_per_min", Value: "60", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device sync requests per minute"},
|
||||
@@ -188,6 +189,12 @@ func (r *SettingsRegistry) ScanPollInterval() time.Duration {
|
||||
// AutoScanEnabled reports whether auto-scanning is on.
|
||||
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
|
||||
|
||||
// RegistrationEnabled reports whether public signup is allowed. Admin user
|
||||
// creation is never affected — only self-service registration rides this
|
||||
// flag; handlers additionally grant the first-user exception (see
|
||||
// handlers/auth.go Register).
|
||||
func (r *SettingsRegistry) RegistrationEnabled() bool { return r.getBool("registration_enabled") }
|
||||
|
||||
// DefaultTimezone returns the configured default timezone name.
|
||||
func (r *SettingsRegistry) DefaultTimezone() string { return r.raw("default_timezone") }
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+162
-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 {
|
||||
@@ -174,6 +192,21 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
||||
req.Role = strings.ToLower(req.Role)
|
||||
}
|
||||
|
||||
// Public registration can be disabled by admins. The giveaway exception
|
||||
// is an unconfigured server: with zero admins the first-user-becomes-
|
||||
// admin bootstrap path must stay reachable (mirrors setupRedirect-
|
||||
// Middleware's "users exist" reasoning). Admin account creation via the
|
||||
// admin API never rides this route, so it is unaffected regardless.
|
||||
if h.settings != nil && !h.settings.RegistrationEnabled() {
|
||||
count, countErr := h.db.CountAdmins(c.Request().Context())
|
||||
if countErr != nil || count > 0 {
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
return c.HTML(http.StatusForbidden, `<div class="text-red-500">Registration is disabled on this server</div>`)
|
||||
}
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "registration is disabled on this server"})
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := h.db.GetUserByEmail(c.Request().Context(), req.Email); err == nil {
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
return c.HTML(http.StatusConflict, `<div class="text-red-500">Email already exists</div>`)
|
||||
@@ -247,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" {
|
||||
@@ -659,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"})
|
||||
}
|
||||
|
||||
@@ -671,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)
|
||||
@@ -1076,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,7 +136,13 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
||||
|
||||
// Add redirect header for HTMX requests
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
c.Response().Header().Set("HX-Redirect", "/collections")
|
||||
// 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,31 +167,46 @@ 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{
|
||||
CollectionID: pgtype.UUID{Bytes: col.ID.Bytes, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
Limit: pgtype.Int4{Int32: 10000, Valid: true},
|
||||
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 countErr == nil {
|
||||
for _, item := range items {
|
||||
if !item.Excluded.Valid || !item.Excluded.Bool {
|
||||
bookCount++
|
||||
}
|
||||
}
|
||||
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: libUUID,
|
||||
})
|
||||
if countErr == nil {
|
||||
bookCount = int(count)
|
||||
}
|
||||
}
|
||||
|
||||
response = append(response, CollectionResponse{
|
||||
@@ -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,9 +87,16 @@ 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)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
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)
|
||||
@@ -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},
|
||||
|
||||
+32
-29
@@ -254,9 +254,9 @@ type KoboInitResponse struct {
|
||||
}
|
||||
|
||||
type KoboSyncStatus struct {
|
||||
Status string `json:"Status"`
|
||||
MarkupsSynced int `json:"MarkupsSynced"`
|
||||
BookmarksSynced int `json:"BookmarksSynced"`
|
||||
Status string `json:"Status"`
|
||||
MarkupsSynced int `json:"MarkupsSynced"`
|
||||
BookmarksSynced int `json:"BookmarksSynced"`
|
||||
DeletedAnnotations []KoboDeletedAnnotation `json:"DeletedAnnotations,omitempty"`
|
||||
}
|
||||
|
||||
@@ -448,16 +448,17 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
|
||||
if h.progressSvc != nil {
|
||||
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Source: "kobo",
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
Percentage: &percentage,
|
||||
CurrentPage: currentPage,
|
||||
TotalPages: totalPages,
|
||||
DeviceType: "kobo",
|
||||
DeviceName: device.DeviceName,
|
||||
Broadcast: true,
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Source: "kobo",
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
Percentage: &percentage,
|
||||
CurrentPage: currentPage,
|
||||
TotalPages: totalPages,
|
||||
DeviceType: "kobo",
|
||||
DeviceName: device.DeviceName,
|
||||
Broadcast: true,
|
||||
ClearLocator: true,
|
||||
})
|
||||
} else {
|
||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||
@@ -798,14 +799,15 @@ func (h *KoboHandler) AnalyticsGettests(c *echo.Context) error {
|
||||
|
||||
if h.progressSvc != nil {
|
||||
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Source: "kobo",
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
Percentage: &percentage,
|
||||
DeviceType: "kobo",
|
||||
DeviceName: device.DeviceName,
|
||||
Broadcast: true,
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Source: "kobo",
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
Percentage: &percentage,
|
||||
DeviceType: "kobo",
|
||||
DeviceName: device.DeviceName,
|
||||
Broadcast: true,
|
||||
ClearLocator: true,
|
||||
})
|
||||
} else {
|
||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||
@@ -860,14 +862,15 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
||||
|
||||
if h.progressSvc != nil {
|
||||
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Source: "bookhoard",
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
Percentage: &percentage,
|
||||
DeviceType: "kobo",
|
||||
DeviceName: device.DeviceName,
|
||||
Broadcast: false,
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Source: "bookhoard",
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
Percentage: &percentage,
|
||||
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
|
||||
@@ -219,24 +234,24 @@ type KOReaderProgressRequest struct {
|
||||
}
|
||||
|
||||
type KOReaderBookProgress struct {
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Authors []string `json:"authors,omitempty"`
|
||||
Progress float64 `json:"progress"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
LastRead string `json:"last_read,omitempty"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
DeviceInfo KOReaderDeviceInfo `json:"device_info,omitempty"`
|
||||
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||
Notes []KOReaderNote `json:"notes,omitempty"`
|
||||
Chapter *int `json:"chapter,omitempty"`
|
||||
Character *int64 `json:"character,omitempty"`
|
||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||
ContextText *string `json:"context_text,omitempty"`
|
||||
Page *int `json:"page,omitempty"`
|
||||
TotalPages *int `json:"total_pages,omitempty"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Authors []string `json:"authors,omitempty"`
|
||||
Progress float64 `json:"progress"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
LastRead string `json:"last_read,omitempty"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
DeviceInfo KOReaderDeviceInfo `json:"device_info,omitempty"`
|
||||
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||
Notes []KOReaderNote `json:"notes,omitempty"`
|
||||
Chapter *int `json:"chapter,omitempty"`
|
||||
Character *int64 `json:"character,omitempty"`
|
||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||
ContextText *string `json:"context_text,omitempty"`
|
||||
Page *int `json:"page,omitempty"`
|
||||
TotalPages *int `json:"total_pages,omitempty"`
|
||||
// Device-side deletions, reported by dedup key. Keys refer to annotations
|
||||
// the device previously received from the server (or echoes of its own
|
||||
// pushes); the device only flags a deletion after observing the key in a
|
||||
@@ -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)
|
||||
|
||||
+239
-17
@@ -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),
|
||||
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,7 +48,11 @@ type QueueItemResponse struct {
|
||||
Status string `json:"status"`
|
||||
ErrorMessage *string `json:"error_message,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ProcessedAt *string `json:"processed_at,omitempty"`
|
||||
// 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"`
|
||||
}
|
||||
|
||||
func (h *QueueHandler) GetDeviceQueueStats(c *echo.Context) error {
|
||||
@@ -136,7 +141,9 @@ 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"),
|
||||
ProcessedAt: timestamptzPtrToString(item.ProcessedAt),
|
||||
CreatedAtDisplay: utils.FormatTimeInTimezone(
|
||||
item.CreatedAt.Time, user.Timezone.String),
|
||||
ProcessedAt: timestamptzPtrToString(item.ProcessedAt),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -189,7 +196,9 @@ 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"),
|
||||
ProcessedAt: timestamptzPtrToString(item.ProcessedAt),
|
||||
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"})
|
||||
}
|
||||
@@ -105,13 +163,14 @@ func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"name": seriesName,
|
||||
"books": bookCards,
|
||||
"total": len(bookCards),
|
||||
"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)
|
||||
|
||||
+289
-28
@@ -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"
|
||||
|
||||
@@ -62,7 +64,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
var buf bytes.Buffer
|
||||
sessionExpired := c.QueryParam("session") == "expired"
|
||||
deleted := c.QueryParam("deleted") == "true"
|
||||
err := templates.Login(sessionExpired, deleted).Render(c.Request().Context(), &buf)
|
||||
err := templates.Login(sessionExpired, deleted, templates.SignupVisible()).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -70,6 +72,13 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
})
|
||||
|
||||
e.GET("/register", func(c *echo.Context) error {
|
||||
// Registration can be disabled by admins. The first-user exception
|
||||
// keeps the page reachable on an unconfigured server (zero admins) —
|
||||
// see templates.SignupVisible for the shared reasoning. Redirect
|
||||
// (not 404) so stale links and bookmarks land somewhere sensible.
|
||||
if !templates.SignupVisible() {
|
||||
return c.Redirect(http.StatusFound, "/login")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err := templates.Register().Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
@@ -101,7 +110,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
loggedIn = err == nil && token.Valid
|
||||
}
|
||||
|
||||
err = templates.Index(loggedIn).Render(c.Request().Context(), &buf)
|
||||
err = templates.Index(loggedIn, templates.SignupVisible()).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -113,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 {
|
||||
@@ -144,27 +153,38 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
totalPages := 1
|
||||
|
||||
if errorMsg == "" {
|
||||
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, perSeriesPage, offset)
|
||||
if err != nil {
|
||||
log.Printf("GetSeriesCardsData failed: %v", err)
|
||||
// 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 {
|
||||
totalPages = (total + perSeriesPage - 1) / perSeriesPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
seriesCards = make([]templates.SeriesCardData, 0, len(seriesList))
|
||||
for _, s := range seriesList {
|
||||
covers := s.CoverPaths
|
||||
if covers == nil {
|
||||
covers = []string{}
|
||||
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"
|
||||
} else {
|
||||
totalPages = (total + perSeriesPage - 1) / perSeriesPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
seriesCards = make([]templates.SeriesCardData, 0, len(seriesList))
|
||||
for _, s := range seriesList {
|
||||
covers := s.CoverPaths
|
||||
if covers == nil {
|
||||
covers = []string{}
|
||||
}
|
||||
seriesCards = append(seriesCards, templates.SeriesCardData{
|
||||
Name: s.Name,
|
||||
LibraryID: s.LibraryID,
|
||||
BookCount: s.BookCount,
|
||||
TotalInSeries: s.TotalInSeries,
|
||||
CoverPaths: covers,
|
||||
})
|
||||
}
|
||||
seriesCards = append(seriesCards, templates.SeriesCardData{
|
||||
Name: s.Name,
|
||||
BookCount: s.BookCount,
|
||||
TotalInSeries: s.TotalInSeries,
|
||||
CoverPaths: covers,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,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"
|
||||
@@ -435,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
|
||||
}
|
||||
@@ -460,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,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)
|
||||
@@ -563,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 {
|
||||
@@ -648,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
|
||||
@@ -658,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)
|
||||
@@ -788,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
|
||||
}
|
||||
@@ -1035,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{},
|
||||
}
|
||||
|
||||
@@ -1218,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
|
||||
@@ -1414,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
|
||||
@@ -1421,6 +1678,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
Rating: rating,
|
||||
Collections: collections,
|
||||
ReadingProgress: progress,
|
||||
BookStats: bookStats,
|
||||
ActiveConflict: activeConflict,
|
||||
NotesCount: len(notes),
|
||||
HighlightsCount: len(highlights),
|
||||
@@ -1429,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"
|
||||
@@ -51,13 +53,16 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
|
||||
}
|
||||
|
||||
return templates.User{
|
||||
ID: userID,
|
||||
Email: userEmail,
|
||||
Username: userUsername,
|
||||
Role: userRole,
|
||||
Theme: userTheme,
|
||||
Token: token,
|
||||
Timezone: userTimezone,
|
||||
ID: userID,
|
||||
Email: userEmail,
|
||||
Username: userUsername,
|
||||
Role: userRole,
|
||||
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
|
||||
}
|
||||
+60
-37
@@ -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
|
||||
@@ -69,7 +71,7 @@ type Config struct {
|
||||
LoginTracker *ratelimit.LoginAttemptTracker
|
||||
ScannerHandler *handlers.Handler
|
||||
JobsHandler *handlers.JobsHandler
|
||||
SidecarHandler *handlers.SidecarHandler
|
||||
SidecarHandler *handlers.SidecarHandler
|
||||
ReaderHandler *handlers.ReaderHandler
|
||||
LibraryService *services.LibraryService
|
||||
}
|
||||
@@ -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,34 +466,50 @@ 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{
|
||||
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},
|
||||
})
|
||||
}
|
||||
|
||||
err := s.db.DeleteUserSystemCollection(ctx, database.DeleteUserSystemCollectionParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Name: collectionName,
|
||||
// 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},
|
||||
QueryType: pgtype.Text{String: meta.QueryType, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
|
||||
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},
|
||||
ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true},
|
||||
QueryType: pgtype.Text{String: meta.QueryType, Valid: true},
|
||||
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
|
||||
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},
|
||||
Icon: pgtype.Text{String: meta.Icon, Valid: true},
|
||||
Color: pgtype.Text{String: meta.Color, Valid: true},
|
||||
ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true},
|
||||
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
|
||||
}
|
||||
@@ -30,7 +30,7 @@ const (
|
||||
)
|
||||
|
||||
var AllowedExtensions = map[string][]string{
|
||||
LibraryTypeEbooks: {".epub", ".pdf", ".mobi", ".azw", ".azw3", ".txt", ".rtf", ".doc", ".docx", ".lit", ".fb2", ".pdb"},
|
||||
LibraryTypeEbooks: {".epub", ".pdf", ".mobi", ".azw", ".azw3", ".txt", ".rtf", ".docx", ".fb2", ".pdb"},
|
||||
LibraryTypeComics: {".cbz", ".cbr", ".cb7", ".cbt", ".epub", ".pdf"},
|
||||
LibraryTypeManga: {".cbz", ".cbr", ".epub", ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".avif", ".tiff", ".tif"},
|
||||
}
|
||||
@@ -59,9 +59,7 @@ var MimeTypes = map[string]string{
|
||||
".azw3": "application/vnd.amazon.ebook",
|
||||
".txt": "text/plain",
|
||||
".rtf": "application/rtf",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".lit": "application/x-msreader",
|
||||
".fb2": "application/x-fictionbook+xml",
|
||||
".pdb": "application/vnd.palm",
|
||||
}
|
||||
@@ -301,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 {
|
||||
|
||||
@@ -662,8 +662,8 @@ func (s *MediaScanner) extractFolderStructureMetadata(path, rootFolder string) *
|
||||
|
||||
var bookExtensions = map[string]bool{
|
||||
".epub": true, ".pdf": true, ".mobi": true, ".azw": true, ".azw3": true,
|
||||
".fb2": true, ".txt": true, ".rtf": true, ".doc": true, ".docx": true,
|
||||
".lit": true, ".pdb": true, ".djvu": true,
|
||||
".fb2": true, ".txt": true, ".rtf": true, ".docx": true,
|
||||
".pdb": true, ".djvu": true,
|
||||
".cbz": true, ".cbr": true, ".cb7": true, ".cbt": true,
|
||||
}
|
||||
|
||||
@@ -974,30 +974,12 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
||||
// Set format group, total characters, and chapter count
|
||||
mimeType := s.getMimeType(path)
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
var formatGroup string
|
||||
var isReflowable, hasFixedLayout bool
|
||||
switch ext {
|
||||
case ".epub":
|
||||
var epubIsFixedLayout bool
|
||||
if ext == ".epub" {
|
||||
isFixed, fixedErr := s.DetectFixedLayoutEPUB(path)
|
||||
if fixedErr == nil && isFixed {
|
||||
formatGroup = "fixed_layout"
|
||||
hasFixedLayout = true
|
||||
} else {
|
||||
formatGroup = "reflowable"
|
||||
isReflowable = true
|
||||
}
|
||||
case ".mobi", ".azw", ".azw3", ".fb2", ".txt":
|
||||
formatGroup = "reflowable"
|
||||
isReflowable = true
|
||||
case ".pdf", ".djvu":
|
||||
formatGroup = "fixed_layout"
|
||||
hasFixedLayout = true
|
||||
case ".cbz", ".cbr", ".cb7", ".cbt":
|
||||
formatGroup = "comic_archive"
|
||||
hasFixedLayout = true
|
||||
default:
|
||||
formatGroup = "unknown"
|
||||
epubIsFixedLayout = fixedErr == nil && isFixed
|
||||
}
|
||||
formatGroup, isReflowable, hasFixedLayout := classifyFormatGroup(ext, epubIsFixedLayout)
|
||||
err = s.db.UpdateMediaItemFormatGroup(ctx, database.UpdateMediaItemFormatGroupParams{
|
||||
ID: createdItem.ID,
|
||||
FormatGroup: formatGroup,
|
||||
@@ -3170,8 +3152,34 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.Me
|
||||
utils.ApplyMetadataOverrides(¶ms, existing)
|
||||
params.MetadataOverrides = utils.MergeOverrides(existing.MetadataOverrides)
|
||||
|
||||
_, err = s.db.UpdateMediaItem(ctx, params)
|
||||
return err
|
||||
if _, err = s.db.UpdateMediaItem(ctx, params); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Format flags are file-derived facts, not user metadata: refresh them on
|
||||
// every update so a force rescan (the admin's backfill tool) can
|
||||
// reclassify rows created before a classifyFormatGroup arm existed.
|
||||
// Mirrors the creation path above.
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
var epubIsFixedLayout bool
|
||||
if ext == ".epub" {
|
||||
isFixed, fixedErr := s.DetectFixedLayoutEPUB(path)
|
||||
epubIsFixedLayout = fixedErr == nil && isFixed
|
||||
}
|
||||
formatGroup, isReflowable, hasFixedLayout := classifyFormatGroup(ext, epubIsFixedLayout)
|
||||
mimeType := s.getMimeType(path)
|
||||
if err := s.db.UpdateMediaItemFormatGroup(ctx, database.UpdateMediaItemFormatGroupParams{
|
||||
ID: existing.ID,
|
||||
FormatGroup: formatGroup,
|
||||
FormatMimetype: pgtype.Text{String: mimeType, Valid: mimeType != ""},
|
||||
IsReflowable: pgtype.Bool{Bool: isReflowable, Valid: true},
|
||||
HasFixedLayout: pgtype.Bool{Bool: hasFixedLayout, Valid: true},
|
||||
TotalCharacters: pgtype.Int8{Int64: metadata.TotalCharacters, Valid: metadata.TotalCharacters > 0},
|
||||
ChapterCount: pgtype.Int4{Int32: metadata.ChapterCount, Valid: metadata.ChapterCount > 0},
|
||||
}); err != nil {
|
||||
fmt.Printf("Warning: failed to update format info for %s: %v\n", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RescanMediaItem re-extracts metadata for a single media item and updates it.
|
||||
@@ -3880,6 +3888,28 @@ func (s *MediaScanner) detectFormatType(filePath string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// classifyFormatGroup maps a lowercased file extension to the format
|
||||
// group and layout flags stored on the media item. `.epub` is passed in
|
||||
// with its detected fixed-layout flag because that check needs the file
|
||||
// itself.
|
||||
func classifyFormatGroup(ext string, epubIsFixedLayout bool) (formatGroup string, isReflowable, hasFixedLayout bool) {
|
||||
switch ext {
|
||||
case ".epub":
|
||||
if epubIsFixedLayout {
|
||||
return "fixed_layout", false, true
|
||||
}
|
||||
return "reflowable", true, false
|
||||
case ".mobi", ".azw", ".azw3", ".fb2", ".txt", ".docx", ".rtf", ".pdb":
|
||||
return "reflowable", true, false
|
||||
case ".pdf", ".djvu":
|
||||
return "fixed_layout", false, true
|
||||
case ".cbz", ".cbr", ".cb7", ".cbt":
|
||||
return "comic_archive", false, true
|
||||
default:
|
||||
return "unknown", false, false
|
||||
}
|
||||
}
|
||||
|
||||
// extractHashInfo calculates hash and extracts OPF identifiers for a file
|
||||
func (s *MediaScanner) extractHashInfo(filePath string) (*HashInfo, *FormatInfo, error) {
|
||||
// Calculate SHA-256
|
||||
|
||||
@@ -119,3 +119,36 @@ func TestProcessDirtyDirectories_CollectsReadyDirs(t *testing.T) {
|
||||
scanner.dirtyDirsMu.RUnlock()
|
||||
assert.Equal(t, 0, count, "Ready directory should be removed from dirty list")
|
||||
}
|
||||
|
||||
func TestClassifyFormatGroup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ext string
|
||||
epubFixed bool
|
||||
group string
|
||||
reflow bool
|
||||
fixed bool
|
||||
}{
|
||||
{"EPUB reflowable", ".epub", false, "reflowable", true, false},
|
||||
{"EPUB fixed-layout", ".epub", true, "fixed_layout", false, true},
|
||||
{"MOBI", ".mobi", false, "reflowable", true, false},
|
||||
{"FB2", ".fb2", false, "reflowable", true, false},
|
||||
{"TXT", ".txt", false, "reflowable", true, false},
|
||||
{"DOCX (was unknown before the fix)", ".docx", false, "reflowable", true, false},
|
||||
{"RTF", ".rtf", false, "reflowable", true, false},
|
||||
{"PDB (PalmDOC)", ".pdb", false, "reflowable", true, false},
|
||||
{"PDF", ".pdf", false, "fixed_layout", false, true},
|
||||
{"DJVU", ".djvu", false, "fixed_layout", false, true},
|
||||
{"CBZ", ".cbz", false, "comic_archive", false, true},
|
||||
{"unrecognized extension", ".xyz", false, "unknown", false, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
group, reflow, fixed := classifyFormatGroup(tt.ext, tt.epubFixed)
|
||||
if group != tt.group || reflow != tt.reflow || fixed != tt.fixed {
|
||||
t.Errorf("classifyFormatGroup(%q, %v) = (%q, %v, %v), want (%q, %v, %v)",
|
||||
tt.ext, tt.epubFixed, group, reflow, fixed, tt.group, tt.reflow, tt.fixed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,16 +26,25 @@ 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,
|
||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||
Offset: pgtype.Int4{Int32: int32(offset), Valid: true},
|
||||
LibraryID: libraryID,
|
||||
VisibleLibraryIds: visibleLibraryIDs,
|
||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||
Offset: pgtype.Int4{Int32: int32(offset), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
@@ -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) != "" {
|
||||
appendNode(n)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
@@ -30,9 +30,7 @@ var mimeTypes = map[string]string{
|
||||
".fb2": "application/x-fictionbook+xml",
|
||||
".txt": "text/plain",
|
||||
".rtf": "application/rtf",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".lit": "application/x-ms-reader",
|
||||
".pdb": "application/vnd.palm",
|
||||
".prc": "application/vnd.palm",
|
||||
}
|
||||
@@ -46,9 +44,7 @@ var ReflowableFormats = map[string]bool{
|
||||
".fb2": true,
|
||||
".txt": true,
|
||||
".rtf": true,
|
||||
".doc": true,
|
||||
".docx": true,
|
||||
".lit": true,
|
||||
".pdb": true,
|
||||
".prc": true,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+117
-4
@@ -291,10 +291,10 @@ func NewProgressService(db *database.Queries, connManager *ConnectionManager) *P
|
||||
}
|
||||
|
||||
type SaveProgressRequest struct {
|
||||
MediaItemID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
Source string
|
||||
DeviceID pgtype.UUID
|
||||
MediaItemID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
Source string
|
||||
DeviceID pgtype.UUID
|
||||
|
||||
Percentage *float64
|
||||
Epubcfi *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 {
|
||||
|
||||
+22
-18
@@ -126,29 +126,29 @@ func (p *SyncQueueProcessor) EnqueueProgress(update *ProgressUpdate) error {
|
||||
}
|
||||
|
||||
type HighlightUpdate struct {
|
||||
DeviceID pgtype.UUID
|
||||
MediaItemID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
SelectionText string
|
||||
StartPosition string
|
||||
EndPosition string
|
||||
Color string
|
||||
NoteText string
|
||||
EpubcfiStart string
|
||||
EpubcfiEnd string
|
||||
DeviceID pgtype.UUID
|
||||
MediaItemID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
SelectionText string
|
||||
StartPosition string
|
||||
EndPosition string
|
||||
Color string
|
||||
NoteText string
|
||||
EpubcfiStart string
|
||||
EpubcfiEnd string
|
||||
PercentageStart float64
|
||||
PercentageEnd float64
|
||||
Source string
|
||||
DeviceSyncData map[string]interface{}
|
||||
Source string
|
||||
DeviceSyncData map[string]interface{}
|
||||
}
|
||||
|
||||
type NoteUpdate struct {
|
||||
DeviceID pgtype.UUID
|
||||
MediaItemID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
Content string
|
||||
Position string
|
||||
Source string
|
||||
DeviceID pgtype.UUID
|
||||
MediaItemID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
Content string
|
||||
Position string
|
||||
Source string
|
||||
DeviceSyncData map[string]interface{}
|
||||
}
|
||||
|
||||
@@ -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#50f56ee",
|
||||
"@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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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"/>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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 {
|
||||
|
||||
+167
-113
@@ -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,123 +75,116 @@ 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-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")
|
||||
</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 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")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">User Management</h1>
|
||||
</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)">
|
||||
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")
|
||||
</span>
|
||||
<div>
|
||||
<div class="text-sm font-medium" style="color: var(--text-primary)">{ user.Username }</div>
|
||||
if user.ID == currentUser.ID {
|
||||
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">You</span>
|
||||
}
|
||||
</div>
|
||||
</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">
|
||||
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"
|
||||
title="Cannot demote the last admin"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin" selected>Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
} else {
|
||||
<select
|
||||
hx-put={ "/api/auth/profile/" + user.ID }
|
||||
hx-target={ "#role-result-" + user.ID }
|
||||
hx-swap="innerHTML"
|
||||
hx-trigger="change"
|
||||
name="role"
|
||||
class="input w-auto py-1 pr-7 text-xs"
|
||||
>
|
||||
<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>
|
||||
<!-- Actions -->
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<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"
|
||||
>
|
||||
@Icon("edit", "h-4 w-4")
|
||||
Edit
|
||||
</button>
|
||||
if user.Role == "admin" && adminCount == 1 {
|
||||
<button
|
||||
disabled
|
||||
class="btn btn-danger text-xs px-2.5 py-1"
|
||||
title="Cannot delete the last admin"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Delete
|
||||
</button>
|
||||
} else {
|
||||
<button
|
||||
hx-delete={ "/api/auth/profile/" + user.ID }
|
||||
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"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<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>
|
||||
|
||||
@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 {
|
||||
<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 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 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>
|
||||
</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 -->
|
||||
<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>
|
||||
} else {
|
||||
<select
|
||||
hx-put={ "/api/auth/profile/" + user.ID }
|
||||
hx-target={ "#role-result-" + user.ID }
|
||||
hx-swap="innerHTML"
|
||||
hx-trigger="change"
|
||||
name="role"
|
||||
class="input w-auto py-1 pr-7 text-xs"
|
||||
>
|
||||
<option value="user" selected?={ user.Role == "user" }>User</option>
|
||||
<option value="admin" selected?={ user.Role == "admin" }>Admin</option>
|
||||
</select>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id={ "role-result-" + user.ID } class="text-xs mt-1"></div>
|
||||
<!-- Actions -->
|
||||
<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-sm px-3 py-1.5 flex-1 sm:flex-none sm:ml-auto"
|
||||
>
|
||||
@Icon("edit", "h-4 w-4")
|
||||
Edit
|
||||
</button>
|
||||
if user.Role == "admin" && adminCount == 1 {
|
||||
<button
|
||||
disabled
|
||||
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")
|
||||
Delete
|
||||
</button>
|
||||
} else {
|
||||
<button
|
||||
hx-delete={ "/api/auth/profile/" + user.ID }
|
||||
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-sm px-3 py-1.5 flex-1 sm:flex-none"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
+179
-130
@@ -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,7 +33,44 @@ 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 }\">")
|
||||
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
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("user", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -37,7 +78,7 @@ 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, 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);\">")
|
||||
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
|
||||
}
|
||||
@@ -45,169 +86,177 @@ 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, 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)\">")
|
||||
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, 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: `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);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("user", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
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, 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: `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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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: `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.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
|
||||
}
|
||||
}
|
||||
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.JoinStringErrs(user.Email)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 116, Col: 87}
|
||||
}
|
||||
_, 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, "</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.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 121, Col: 73}
|
||||
}
|
||||
_, 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, "</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 == "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>")
|
||||
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
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<select hx-put=\"")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 77, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" hx-target=\"")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 78, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "user" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " selected")
|
||||
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=\"")
|
||||
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: `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>")
|
||||
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.ResolveAttributeValue("#role-result-" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 139, Col: 48}
|
||||
}
|
||||
_, 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, 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, 20, "</td><!-- Created --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 92, Col: 126}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(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, 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: `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: `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: `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
|
||||
}
|
||||
|
||||
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user