docs(ui): cookie-rendered sidebar state, data-collapsed CSS, working overview links + link-checker gate
Three problems, one root cause each: 1. Sidebar jump: the server rendered its own default (current section open, rest collapsed) and restoreDocsNav corrected it after paint — open-then-close. Fix: persistence moved server-side. toggleCollapsible now writes a docs_nav cookie (strictly-necessary UI state, no identifiers, SameSite=Lax); ApplyNavOverrides merges it into the nav before render, so first paint IS the visitor's state. The post-paint restore is deleted. 2. Arrows out of sync / double-click: two visibility mechanisms disagreed. There was NO data-collapsed CSS at all — collapsed sections were only ever hidden by JS after paint, and the arrow rotation came from a different (server) state. Now: style.css hides on [data-collapsed=true] (single source of truth, server-rendered), applyCollapsed sets only the attribute, arrows rotate from the same merged value. Live check: 37/37 collapsibles arrow-consistent. 3. api-reference.md links: all 20+ quick links were directory-style (authentication/ etc.) that 404 on the docs site, plus stale targets (books/, ../collections-api.md, websocket 'coming soon'). Rewritten to real per-file links — all 9 unique targets return 200 live. Same rot fixed in admin/hash-conflicts.md, development.md, android-app.md, create_media_item.md, koreader-protocol.md; docs/index.md redirected from the deleted collections-api monolith (stale 530-line duplicate of the gated api/collections docs — deleted, per the WO2 doctrine). New gate: TestDocsRelativeLinks walks docs/developer/** and fails make test on any relative link that is directory-style or unresolvable — overview links can never silently rot again.
This commit is contained in:
1 parent
f1a6de7cd7
commit
a8f8c4def9
17 files changed
+241
-674
No files matched your search
@@ -162,4 +162,4 @@ iOS is a real roadmap item but not near-term. The strategy is **not** to pre-pay
|
||||
- **[API Reference](api/api-reference.md)** - Complete REST API
|
||||
- **[WebSocket API](websocket-api.md)** - Real-time sync events
|
||||
- **[Sync Guide](../user/sync-guide.md)** - How universal sync works
|
||||
- **[Devices API](api/devices/)** - Device registration and approval
|
||||
- **[Devices API](api/devices/get_devices.md)** - Device registration and approval
|
||||
@@ -107,5 +107,5 @@ Files on disk are never deleted — resolution only affects database rows.
|
||||
|
||||
## Related Endpoints
|
||||
|
||||
- [System Settings API](../system/settings.md) — scanning configuration
|
||||
- [Scanner API](../scanner/) — triggering scans and watch mode
|
||||
- [System Settings API](../system/get_settings.md) — scanning configuration
|
||||
- [Scanner API](../scanner/scan_library.md) — triggering scans and watch mode
|
||||
@@ -4,34 +4,33 @@ Complete reference for Bookhoard REST API endpoints.
|
||||
|
||||
## Quick Links
|
||||
|
||||
- [Authentication](authentication/) - User registration, login, tokens
|
||||
- [Users](users/) - Profile management
|
||||
- [Admin](admin/) - Admin operations (user management)
|
||||
- [Libraries](libraries/) - Library management
|
||||
- [Books](books/) - Bulk book operations and downloads
|
||||
- [Media Items](media-items/) - Media item operations
|
||||
- [Progress](progress/) - Reading progress tracking
|
||||
- [Notes](notes/) - User notes management
|
||||
- [Highlights](highlights/) - Book highlights
|
||||
- [Ratings](ratings/) - Book ratings
|
||||
- [Devices](devices/) - Device registration, sync, and shelf management
|
||||
- [Analytics](analytics/) - Usage statistics
|
||||
- [Book Matching](book-matching/) - Search and link books
|
||||
- [Collections](collections/) - See [Collections API](../collections-api.md)
|
||||
- [Conflicts](conflicts/) - Sync conflict resolution
|
||||
- [Queue](queue/) - Sync queue management
|
||||
- [Scanner](scanner/) - Library scanning and watch mode (admin)
|
||||
- [System](system/) - Tunable system settings and configuration (admin)
|
||||
- [OPDS](opds/) - Open Publication Distribution
|
||||
- [KOReader](koreader/) - KOReader sync protocol
|
||||
- [Kobo](kobo/) - Kobo sync protocol (coming soon)
|
||||
- [WebSocket](websocket/) - Real-time sync events
|
||||
- [Authentication](authentication/login.md) - User registration, login, tokens
|
||||
- [Users](users/get_profile.md) - Profile management
|
||||
- [Admin](admin/list_users.md) - Admin operations (user management)
|
||||
- [Libraries](libraries/list_libraries.md) - Library management
|
||||
- [Media Items](media-items/get_media_item.md) - Media item operations
|
||||
- [Progress](progress/get_media_progress.md) - Reading progress tracking
|
||||
- [Notes](notes/get_notes.md) - User notes management
|
||||
- [Highlights](highlights/get_highlights.md) - Book highlights
|
||||
- [Ratings](ratings/get_ratings.md) - Book ratings
|
||||
- [Devices](devices/get_devices.md) - Device registration, sync, and shelf management
|
||||
- [Analytics](analytics/get_analytics.md) - Usage statistics
|
||||
- [Book Matching](book-matching/search_books.md) - Search and link books
|
||||
See [Collections Endpoints](collections/list_collections.md)
|
||||
- [Conflicts](conflicts/list_conflicts.md) - Sync conflict resolution
|
||||
- [Queue](queue/list_all_queue_items.md) - Sync queue management
|
||||
- [Scanner](scanner/scan_library.md) - Library scanning and watch mode (admin)
|
||||
- [System](system/get_settings.md) - Tunable system settings and configuration (admin)
|
||||
- [OPDS](opds/feeds.md) - Open Publication Distribution
|
||||
- [KOReader](koreader/sync_progress.md) - KOReader sync protocol
|
||||
- [Kobo](kobo/initialization.md) - Kobo sync protocol (coming soon)
|
||||
- [WebSocket](websocket/protocol.md) - Real-time sync events
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
See [Authentication Endpoints](authentication/)
|
||||
See [Authentication Endpoints](authentication/login.md)
|
||||
|
||||
- POST /api/auth/register - Register new user
|
||||
- POST /api/auth/login - User login
|
||||
@@ -48,7 +47,7 @@ See [Authentication Endpoints](authentication/)
|
||||
|
||||
## Admin
|
||||
|
||||
See [Admin Operations](admin/)
|
||||
See [Admin Operations](admin/list_users.md)
|
||||
|
||||
- GET /api/auth/users - List all users (admin)
|
||||
- PUT /api/auth/users/:id/max-devices - Update user device limit (admin)
|
||||
@@ -57,11 +56,11 @@ See [Admin Operations](admin/)
|
||||
|
||||
## Users & Profiles
|
||||
|
||||
See [User Management](users/)
|
||||
See [User Management](users/get_profile.md)
|
||||
|
||||
## Libraries
|
||||
|
||||
See [Library Management](libraries/)
|
||||
See [Library Management](libraries/list_libraries.md)
|
||||
|
||||
- GET /api/libraries/types - Get library types
|
||||
- POST /api/libraries - Create library (admin)
|
||||
@@ -83,13 +82,13 @@ See [Library Management](libraries/)
|
||||
|
||||
## Media Items
|
||||
|
||||
See [Media Item Operations](media-items/)
|
||||
See [Media Item Operations](media-items/get_media_item.md)
|
||||
|
||||
- GET /api/media-items - List media items
|
||||
- GET /api/media-items/:id - Get media item details
|
||||
- POST /api/media-items/bulk-delete - Bulk delete media items
|
||||
- POST /api/media-items/bulk-update - Bulk update media items (tags/contributors with normalization)
|
||||
- GET /uploads/library-{library_id}/{file_path} - Download book file / cover (JWT; see [Download Media Item](media-items/download_media_item.md))
|
||||
- GET /uploads/library-{library_id}/{file_path} - Download book file / cover (JWT; see [Download Media Item](media-items/download_media_item_file.md))
|
||||
- POST /api/media-items/:id/rating - Create rating
|
||||
- GET /api/media-items/:id/rating - Get rating
|
||||
- PUT /api/media-items/:id/rating - Update rating
|
||||
@@ -117,7 +116,7 @@ See [Media Item Operations](media-items/)
|
||||
|
||||
## Reading Progress
|
||||
|
||||
See [Progress Tracking](progress/) — in particular the
|
||||
See [Progress Tracking](progress/get_media_progress.md) — in particular the
|
||||
[Position Contract](progress/position-contract.md) (verification,
|
||||
healing, the OPF spine numbering hazard, and offset currencies) before
|
||||
writing a client.
|
||||
@@ -129,15 +128,15 @@ writing a client.
|
||||
|
||||
## Notes & Highlights
|
||||
|
||||
See [Notes Management](notes/) and [Highlights Management](highlights/)
|
||||
See [Notes Management](notes/get_notes.md) and [Highlights Management](highlights/get_highlights.md)
|
||||
|
||||
## Ratings
|
||||
|
||||
See [Ratings System](ratings/)
|
||||
See [Ratings System](ratings/get_ratings.md)
|
||||
|
||||
## Device Management
|
||||
|
||||
See [Device Registration & Sync](devices/)
|
||||
See [Device Registration & Sync](devices/get_devices.md)
|
||||
|
||||
- POST /api/devices/register - Initiate device registration
|
||||
- POST /api/devices/register/status - Check registration status
|
||||
@@ -157,7 +156,7 @@ See [Device Registration & Sync](devices/)
|
||||
|
||||
## System Settings & Configuration
|
||||
|
||||
See [System API](system/)
|
||||
See [System API](system/get_settings.md)
|
||||
|
||||
- GET /api/system/settings - List all tunable settings with metadata (admin)
|
||||
- PUT /api/system/settings - Validate, persist, and reload a single setting (admin)
|
||||
@@ -166,7 +165,7 @@ See [System API](system/)
|
||||
|
||||
## Analytics
|
||||
|
||||
See [Usage Analytics](analytics/)
|
||||
See [Usage Analytics](analytics/get_analytics.md)
|
||||
|
||||
- GET /api/analytics/reading-stats - Get reading statistics
|
||||
- GET /api/analytics/device-usage - Get device usage stats
|
||||
@@ -174,7 +173,7 @@ See [Usage Analytics](analytics/)
|
||||
|
||||
## Book Matching & Linking
|
||||
|
||||
See [Book Matching](book-matching/)
|
||||
See [Book Matching](book-matching/search_books.md)
|
||||
|
||||
- GET /api/media-items/search - Search media items
|
||||
- POST /api/sync/books/query - Query books for matching
|
||||
@@ -191,11 +190,11 @@ See [Book Matching](book-matching/)
|
||||
|
||||
## Collections
|
||||
|
||||
See [Collections API](../collections-api.md) or [Collections Endpoints](collections/)
|
||||
See [Collections Endpoints](collections/list_collections.md)
|
||||
|
||||
## Conflicts
|
||||
|
||||
See [Conflict Resolution](conflicts/)
|
||||
See [Conflict Resolution](conflicts/list_conflicts.md)
|
||||
|
||||
- GET /api/conflicts - List conflicts
|
||||
- GET /api/conflicts/:id - Get conflict details
|
||||
@@ -207,7 +206,7 @@ See [Conflict Resolution](conflicts/)
|
||||
|
||||
## Queue Management
|
||||
|
||||
See [Sync Queue](queue/)
|
||||
See [Sync Queue](queue/list_all_queue_items.md)
|
||||
|
||||
- GET /api/queue/devices/:device_id/stats - Get device queue stats
|
||||
- GET /api/queue/devices/:device_id/items - List device queue items
|
||||
@@ -218,7 +217,7 @@ See [Sync Queue](queue/)
|
||||
|
||||
## Scanner (Admin)
|
||||
|
||||
See [Scanner Operations](scanner/)
|
||||
See [Scanner Operations](scanner/scan_library.md)
|
||||
|
||||
- [Scanner Overview](scanner/overview.md) - Supported formats, metadata extraction, and features
|
||||
- POST /api/scanner/scan - Scan library (one-time) (ebooks, manga, comics)
|
||||
@@ -231,7 +230,7 @@ See [Scanner Operations](scanner/)
|
||||
|
||||
## OPDS
|
||||
|
||||
See [OPDS Feeds](opds/)
|
||||
See [OPDS Feeds](opds/feeds.md)
|
||||
|
||||
- GET /opds/devices/:deviceId/catalog - Get device catalog
|
||||
- GET /opds/devices/:deviceId/search - Search device catalog
|
||||
@@ -242,7 +241,7 @@ See [OPDS Feeds](opds/)
|
||||
|
||||
## KOReader Sync Protocol
|
||||
|
||||
See [KOReader Sync](koreader/) and [Sync Protocol](sync/koreader-protocol.md)
|
||||
See [KOReader Sync](koreader/sync_progress.md) and [Sync Protocol](sync/koreader-protocol.md)
|
||||
|
||||
- POST /api/sync/koreader/progress - Sync reading progress
|
||||
- GET /api/sync/koreader/resolve?sha256={hash} - Resolve a book UUID by file SHA-256
|
||||
@@ -254,7 +253,7 @@ See [KOReader Sync](koreader/) and [Sync Protocol](sync/koreader-protocol.md)
|
||||
|
||||
> **Status: Coming Soon** — Native Kobo sync is implemented server-side but not yet supported on real devices. These endpoints are under active development and may change.
|
||||
|
||||
See [Kobo Sync](kobo/) and [Sync Protocol](sync/kobo-protocol.md)
|
||||
See [Kobo Sync](kobo/initialization.md) and [Sync Protocol](sync/kobo-protocol.md)
|
||||
|
||||
- POST /api/sync/kobo/:token/markup - Sync markup highlights
|
||||
- POST /api/sync/kobo/:token/bookmark - Sync bookmarks
|
||||
@@ -264,7 +263,7 @@ See [Kobo Sync](kobo/) and [Sync Protocol](sync/kobo-protocol.md)
|
||||
|
||||
## WebSocket
|
||||
|
||||
See [WebSocket API](websocket/)
|
||||
See [WebSocket API](websocket/protocol.md)
|
||||
|
||||
- WS /ws/sync - Real-time sync events and notifications
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# Collections API
|
||||
|
||||
See [Collections API](../../collections-api.md) for complete collections documentation.
|
||||
|
||||
## Quick Links
|
||||
|
||||
- [List Collections](list_collections.md) - Get all collections
|
||||
- [Get Collection](get_collection.md) - Get collection details
|
||||
- [Create Collection](create_collection.md) - Create a new collection
|
||||
- [Update Collection](update_collection.md) - Update collection metadata
|
||||
- [Delete Collection](delete_collection.md) - Delete a collection
|
||||
- [Add Auto-Assign Rule](add_auto_assign_rule.md) - Add automatic book assignment rule
|
||||
- [Remove Auto-Assign Rule](remove_auto_assign_rule.md) - Remove assignment rule
|
||||
- [Test Rule](test_rule.md) - Test assignment rule
|
||||
- [Bulk Assign](bulk_assign.md) - Bulk assign books to collection
|
||||
- [Create Shelf Mapping](create_shelf_mapping.md) - Map collection to device shelf
|
||||
- [Delete Shelf Mapping](delete_shelf_mapping.md) - Remove shelf mapping
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Collections allow you to organize your books into custom groups with automatic assignment rules.
|
||||
|
||||
## Features
|
||||
|
||||
- **Manual Assignment**: Add specific books to collections
|
||||
- **Auto-Assignment**: Define rules to automatically assign books based on metadata
|
||||
- **Device Sync**: Collections sync as shelves on Kobo/KOReader devices
|
||||
- **Flexible Rules**: Filter by genre, series, tags, and more
|
||||
|
||||
## See Also
|
||||
|
||||
- [Collections API](../../collections-api.md) - Complete API reference
|
||||
- [Sync Guide](../../user/sync-guide.md) - How collections sync to devices
|
||||
@@ -22,7 +22,7 @@ Create a new media item (Admin only).
|
||||
2. Add folder(s) to the library: `POST /api/libraries/{library_id}/folders`
|
||||
3. Then create media items: `POST /api/media-items`
|
||||
|
||||
See [Library API documentation](../libraries/) for more details.
|
||||
See [Library API documentation](../libraries/list_libraries.md) for more details.
|
||||
|
||||
## Request Body
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ as a server annotation deletion.
|
||||
|
||||
Every client/sync interface (KOReader, Kobo, OPDS, the device-link UI, and any
|
||||
future mobile app) resolves books through a single shared service:
|
||||
[`internal/services/book_resolver.go`](../../../internal/services/book_resolver.go).
|
||||
`internal/services/book_resolver.go` (in the repository source).
|
||||
The import-time SHA-256 (stored on `media_items.file_sha256`, plus a per-format
|
||||
hash on `media_item_formats.file_sha256` for KEPUB/PDF) is the canonical shared
|
||||
identifier. New clients should resolve by SHA-256 via `BookResolver` rather than
|
||||
|
||||
@@ -1,530 +0,0 @@
|
||||
# Collections API Documentation
|
||||
|
||||
Complete API reference for collection management endpoints.
|
||||
|
||||
**Base Path**: `/api/collections`
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Collections allow you to organize your books into custom categories with:
|
||||
|
||||
- **Auto-assignment rules**: Automatically add books matching criteria
|
||||
- **View settings**: Per-device display preferences
|
||||
- **Shelf mappings**: Sync to device-specific shelves (Kobo, KOReader)
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### List All Collections
|
||||
|
||||
**Endpoint**: `GET /api/collections`
|
||||
**Authentication**: Required
|
||||
**Description**: Get all collections for the authenticated user
|
||||
|
||||
**Response** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"collections": [
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Science Fiction",
|
||||
"description": "My sci-fi collection",
|
||||
"color": "#FF5733",
|
||||
"icon": "🚀",
|
||||
"auto_assign_rules": [
|
||||
{
|
||||
"field": "genre",
|
||||
"operator": "equals",
|
||||
"value": "Science Fiction",
|
||||
"priority": 1,
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"view_settings": {
|
||||
"kobo": {
|
||||
"view_mode": "grid",
|
||||
"sort_order": "name",
|
||||
"items_per_page": 24
|
||||
}
|
||||
},
|
||||
"created_at": "2026-02-01T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Create Collection
|
||||
|
||||
**Endpoint**: `POST /api/collections`
|
||||
**Authentication**: Required
|
||||
**Description**: Create a new collection
|
||||
|
||||
**Request Body**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "To Read",
|
||||
"description": "Books I want to read soon",
|
||||
"color": "#00FF00",
|
||||
"icon": "📖",
|
||||
"auto_assign_rules": [
|
||||
{
|
||||
"field": "tags",
|
||||
"operator": "contains",
|
||||
"value": "to-read",
|
||||
"priority": 1,
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"view_settings": {
|
||||
"kobo": {
|
||||
"view_mode": "grid"
|
||||
},
|
||||
"koreader": {
|
||||
"view_mode": "list"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `name` (required): Collection name (max 255 chars)
|
||||
- `description` (optional): Collection description
|
||||
- `color` (optional): Hex color code (e.g., "#FF5733")
|
||||
- `icon` (optional): Emoji icon (e.g., "🚀", "📖")
|
||||
- `auto_assign_rules` (optional): Array of rule objects
|
||||
- `view_settings` (optional): Per-device display preferences
|
||||
|
||||
**Rule Object**:
|
||||
|
||||
- `field`: Field to match on (genre, author, series, language, publisher, copyright_year, tags)
|
||||
- `operator`: Comparison operator (equals, not_equals, contains, not_contains, starts_with, ends_with, greater_than, less_than)
|
||||
- `value`: Value to compare against
|
||||
- `priority`: Rule priority (1 = highest)
|
||||
- `enabled`: Whether rule is active
|
||||
|
||||
**Response** (201 Created): Collection object
|
||||
|
||||
### Get Collection Details
|
||||
|
||||
**Endpoint**: `GET /api/collections/{id}`
|
||||
**Authentication**: Required
|
||||
**Description**: Get single collection with all books
|
||||
|
||||
**Path Parameters**:
|
||||
|
||||
- `id`: Collection UUID
|
||||
|
||||
**Response** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Science Fiction",
|
||||
"description": "My sci-fi collection",
|
||||
"color": "#FF5733",
|
||||
"icon": "🚀",
|
||||
"books": [
|
||||
{
|
||||
"media_item_id": "660e8400-e29b-41d4-a716-446655440000",
|
||||
"title": "Foundation",
|
||||
"author": "Isaac Asimov",
|
||||
"cover_image_path": "/covers/foundation.jpg"
|
||||
}
|
||||
],
|
||||
"auto_assign_rules": [],
|
||||
"view_settings": {},
|
||||
"created_at": "2026-02-01T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Update Collection
|
||||
|
||||
**Endpoint**: `PUT /api/collections/{id}`
|
||||
**Authentication**: Required
|
||||
**Description**: Update collection details
|
||||
|
||||
**Path Parameters**:
|
||||
|
||||
- `id`: Collection UUID
|
||||
|
||||
**Request Body**: All fields are optional
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Sci-Fi Favorites",
|
||||
"description": "Updated description",
|
||||
"color": "#BLUE",
|
||||
"icon": "🌟"
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (200 OK): Updated collection object
|
||||
|
||||
### Delete Collection
|
||||
|
||||
**Endpoint**: `DELETE /api/collections/{id}`
|
||||
**Authentication**: Required
|
||||
**Description**: Delete a collection (books are NOT deleted)
|
||||
|
||||
**Path Parameters**:
|
||||
|
||||
- `id`: Collection UUID
|
||||
|
||||
**Response** (204 No Content)
|
||||
|
||||
---
|
||||
|
||||
## Collection Books
|
||||
|
||||
### Add Books to Collection
|
||||
|
||||
**Endpoint**: `POST /api/collections/{id}/books`
|
||||
**Authentication**: Required
|
||||
**Description**: Add one or more books to a collection
|
||||
|
||||
**Path Parameters**:
|
||||
|
||||
- `id`: Collection UUID
|
||||
|
||||
**Request Body**:
|
||||
|
||||
```json
|
||||
{
|
||||
"book_ids": [
|
||||
"660e8400-e29b-41d4-a716-446655440000",
|
||||
"770e8400-e29b-41d4-a716-446655440000",
|
||||
"880e8400-e29b-41d4-a716-446655440000"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (204 No Content)
|
||||
|
||||
### Remove Book from Collection
|
||||
|
||||
**Endpoint**: `DELETE /api/collections/{id}/books/{bookId}`
|
||||
**Authentication**: Required
|
||||
**Description**: Remove a single book from a collection
|
||||
|
||||
**Path Parameters**:
|
||||
|
||||
- `id`: Collection UUID
|
||||
- `bookId`: Media Item UUID
|
||||
|
||||
**Response** (204 No Content)
|
||||
|
||||
### Bulk Remove Books
|
||||
|
||||
**Endpoint**: `POST /api/collections/{id}/books/bulk-remove`
|
||||
**Authentication**: Required
|
||||
**Description**: Remove multiple books at once (efficient)
|
||||
|
||||
**Path Parameters**:
|
||||
|
||||
- `id`: Collection UUID
|
||||
|
||||
**Request Body**:
|
||||
|
||||
```json
|
||||
{
|
||||
"book_ids": [
|
||||
"660e8400-e29b-41d4-a716-446655440000",
|
||||
"770e8400-e29b-41d4-a716-446655440000"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"removed": 2,
|
||||
"total": 2
|
||||
}
|
||||
```
|
||||
|
||||
**Example**: Removing 2 books, both successfully removed
|
||||
|
||||
### Get Collection Books
|
||||
|
||||
**Endpoint**: `GET /api/collections/{id}/books`
|
||||
**Authentication**: Required
|
||||
**Description**: Get all books in a collection
|
||||
|
||||
**Path Parameters**:
|
||||
|
||||
- `id`: Collection UUID
|
||||
|
||||
**Query Parameters**:
|
||||
|
||||
- `limit` (optional): Number of books to return (default: 50)
|
||||
- `offset` (optional): Number of books to skip (default: 0)
|
||||
|
||||
**Response** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"books": [
|
||||
{
|
||||
"media_item_id": "660e8400-e29b-41d4-a716-446655440000",
|
||||
"title": "Foundation",
|
||||
"author": "Isaac Asimov",
|
||||
"cover_image_path": "/covers/foundation.jpg",
|
||||
"added_at": "2026-02-01T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Collection Rules
|
||||
|
||||
### Test Collection Rules
|
||||
|
||||
**Endpoint**: `POST /api/collections/test-rules`
|
||||
**Authentication**: Required
|
||||
**Description**: Test which books would match given rules (without saving)
|
||||
|
||||
**Request Body**:
|
||||
|
||||
```json
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"field": "genre",
|
||||
"operator": "equals",
|
||||
"value": "Science Fiction"
|
||||
},
|
||||
{
|
||||
"field": "author",
|
||||
"operator": "contains",
|
||||
"value": "Asimov"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Supported Fields**:
|
||||
|
||||
- `genre`: Book genre
|
||||
- `author`: Book author
|
||||
- `series`: Book series name
|
||||
- `language`: Book language
|
||||
- `publisher`: Publisher name
|
||||
- `copyright_year`: Publication year (numeric comparison)
|
||||
- `tags`: Book tags
|
||||
|
||||
**Supported Operators**:
|
||||
|
||||
- `equals`: Exact match
|
||||
- `not_equals`: Not equal
|
||||
- `contains`: Contains substring (case-insensitive)
|
||||
- `not_contains`: Does not contain
|
||||
- `starts_with`: Starts with (case-insensitive)
|
||||
- `ends_with`: Ends with (case-insensitive)
|
||||
- `greater_than`: Greater than (numeric)
|
||||
- `less_than`: Less than (numeric)
|
||||
|
||||
**Response** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"matches": [
|
||||
{
|
||||
"media_item_id": "660e8400-e29b-41d4-a716-446655440000",
|
||||
"title": "Foundation",
|
||||
"author": "Isaac Asimov",
|
||||
"cover_image_path": "/covers/foundation.jpg",
|
||||
"match_reason": "Matched rule: genre equals Science Fiction"
|
||||
}
|
||||
],
|
||||
"total": 42
|
||||
}
|
||||
```
|
||||
|
||||
**Use Case**: Test rules before creating collection to verify correct book matching
|
||||
|
||||
---
|
||||
|
||||
## Device Shelf Mappings
|
||||
|
||||
### Get Device Shelf Mappings
|
||||
|
||||
**Endpoint**: `GET /api/devices/{deviceId}/collections`
|
||||
**Authentication**: Required
|
||||
**Description**: Get all collection-to-shelf mappings for a device
|
||||
|
||||
**Path Parameters**:
|
||||
|
||||
- `deviceId`: Device UUID
|
||||
|
||||
**Response** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"mappings": [
|
||||
{
|
||||
"id": "990e8400-e29b-41d4-a716-446655440000",
|
||||
"collection_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"collection_name": "Science Fiction",
|
||||
"device_shelf_name": "Sci-Fi",
|
||||
"sync_direction": "bidirectional",
|
||||
"created_at": "2026-02-01T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Create Shelf Mapping
|
||||
|
||||
**Endpoint**: `POST /api/devices/{deviceId}/collections`
|
||||
**Authentication**: Required
|
||||
**Description**: Map a collection to a device shelf
|
||||
|
||||
**Path Parameters**:
|
||||
|
||||
- `deviceId`: Device UUID
|
||||
|
||||
**Request Body**:
|
||||
|
||||
```json
|
||||
{
|
||||
"collection_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"device_shelf_name": "Sci-Fi",
|
||||
"sync_direction": "bidirectional"
|
||||
}
|
||||
```
|
||||
|
||||
**Sync Directions**:
|
||||
|
||||
- `bidirectional`: Sync both ways between Bookhoard and device
|
||||
- `book_to_hoard`: Bookhoard → Device only
|
||||
- `device_to_hoard`: Device → Bookhoard only
|
||||
- `none`: No sync (mapping only)
|
||||
|
||||
**Response** (201 Created): Mapping object
|
||||
|
||||
### Update Shelf Mapping
|
||||
|
||||
**Endpoint**: `PUT /api/devices/{deviceId}/collections/{collectionId}`
|
||||
**Authentication**: Required
|
||||
**Description**: Update existing shelf mapping
|
||||
|
||||
**Path Parameters**:
|
||||
|
||||
- `deviceId`: Device UUID
|
||||
- `collectionId`: Collection UUID
|
||||
|
||||
**Request Body**:
|
||||
|
||||
```json
|
||||
{
|
||||
"device_shelf_name": "Science Fiction",
|
||||
"sync_direction": "book_to_hoard"
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (200 OK): Updated mapping object
|
||||
|
||||
### Delete Shelf Mapping
|
||||
|
||||
**Endpoint**: `DELETE /api/devices/{deviceId}/collections/{collectionId}`
|
||||
**Authentication**: Required
|
||||
**Description**: Remove shelf mapping
|
||||
|
||||
**Path Parameters**:
|
||||
|
||||
- `deviceId`: Device UUID
|
||||
- `collectionId`: Collection UUID
|
||||
|
||||
**Response** (204 No Content)
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
All endpoints may return these errors:
|
||||
|
||||
**400 Bad Request**:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "invalid request: validation failed"
|
||||
}
|
||||
```
|
||||
|
||||
**401 Unauthorized**:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "authentication required"
|
||||
}
|
||||
```
|
||||
|
||||
**404 Not Found**:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "collection not found"
|
||||
}
|
||||
```
|
||||
|
||||
**500 Internal Server Error**:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "internal server error"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
- **Authenticated**: 100 requests per minute
|
||||
- **Unauthenticated**: 10 requests per minute
|
||||
|
||||
Headers included:
|
||||
|
||||
```
|
||||
X-RateLimit-Limit: 100
|
||||
X-RateLimit-Remaining: 95
|
||||
X-RateLimit-Reset: 1643723400
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bruno OpenCollection YAML Tests
|
||||
|
||||
Complete API tests available in `bruno/collections/`:
|
||||
|
||||
- `Get Collections.yml`
|
||||
- `Create Collection.yml`
|
||||
- `Get Collection.yml`
|
||||
- `Update Collection.yml`
|
||||
- `Delete Collection.yml`
|
||||
- `Add Books to Collection.yml`
|
||||
- `Remove Book from Collection.yml`
|
||||
- `Get Book Collections.yml`
|
||||
- `Test Collection Rules.yml`
|
||||
- `Bulk Remove Books.yml`
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
bruno run bruno/collections/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-02-01
|
||||
**Version**: 1.0
|
||||
@@ -6,7 +6,7 @@ Welcome to the Bookhoard developer documentation. This section contains API refe
|
||||
|
||||
### Complete API Reference
|
||||
|
||||
- **[API Reference](api-reference.md)** - Complete REST API reference
|
||||
- **[API Reference](api/api-reference.md)** - Complete REST API reference
|
||||
- Authentication
|
||||
- User management
|
||||
- Libraries
|
||||
@@ -24,25 +24,25 @@ Welcome to the Bookhoard developer documentation. This section contains API refe
|
||||
Browse individual endpoint documentation with interactive API Explorer:
|
||||
|
||||
- **[API Documentation](api/api-reference.md)** - Split endpoint docs
|
||||
- [Authentication](api/authentication/) - Register, login, tokens
|
||||
- [Users](api/users/) - Profile management
|
||||
- [Libraries](api/libraries/) - Library operations
|
||||
- [Media Items](api/media-items/) - Book/ebook management
|
||||
- [Progress](api/progress/) - Reading progress tracking
|
||||
- [Notes](api/notes/) - User notes
|
||||
- [Highlights](api/highlights/) - Book highlights
|
||||
- [Ratings](api/ratings/) - Book ratings
|
||||
- [Devices](api/devices/) - Device management
|
||||
- [Analytics](api/analytics/) - Usage statistics
|
||||
- [Book Matching](api/book-matching/) - Search and link books
|
||||
- [Collections](api/collections/) - Collection management
|
||||
- [OPDS](api/opds/) - Open Publication Distribution
|
||||
- [Sync Protocols](api/sync/) - KOReader and Kobo sync
|
||||
- [WebSocket](api/websocket/) - Real-time events
|
||||
- [Authentication](api/authentication/login.md) - Register, login, tokens
|
||||
- [Users](api/users/get_profile.md) - Profile management
|
||||
- [Libraries](api/libraries/list_libraries.md) - Library operations
|
||||
- [Media Items](api/media-items/get_media_item.md) - Book/ebook management
|
||||
- [Progress](api/progress/get_media_progress.md) - Reading progress tracking
|
||||
- [Notes](api/notes/get_notes.md) - User notes
|
||||
- [Highlights](api/highlights/get_highlights.md) - Book highlights
|
||||
- [Ratings](api/ratings/get_ratings.md) - Book ratings
|
||||
- [Devices](api/devices/get_devices.md) - Device management
|
||||
- [Analytics](api/analytics/get_analytics.md) - Usage statistics
|
||||
- [Book Matching](api/book-matching/search_books.md) - Search and link books
|
||||
- [Collections](api/collections/list_collections.md) - Collection management
|
||||
- [OPDS](api/opds/feeds.md) - Open Publication Distribution
|
||||
- [Sync Protocols](api/sync/koreader-protocol.md) - KOReader and Kobo sync
|
||||
- [WebSocket](api/websocket/protocol.md) - Real-time events
|
||||
|
||||
### Collections API
|
||||
|
||||
- **[Collections API](collections-api.md)** - Complete collections API reference
|
||||
- **[Collections API](api/collections/list_collections.md)** - Complete collections API reference
|
||||
- Create and manage collections
|
||||
- Auto-assign rules
|
||||
- Test rules
|
||||
|
||||
+2
-2
@@ -30,7 +30,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
||||
- **API Documentation**
|
||||
- [Complete API Reference](developer/api-reference.md) - Monolithic REST API reference (1,300+ lines)
|
||||
- [Split Endpoint Docs](developer/api/api-reference.md) - Individual endpoints with interactive API Explorer
|
||||
- [Collections API](developer/collections-api.md) - Collections management API
|
||||
- [Collections API](developer/api/collections/list_collections.md) - Collections management API
|
||||
- [Admin API](developer/api/admin/) - User management (admin operations)
|
||||
- [Media Items API](developer/api/media-items/) - Media item operations including bulk operations and downloads
|
||||
- [Devices API](developer/api/devices/) - Device sync and shelf management
|
||||
@@ -121,7 +121,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
||||
### Integrate with Bookhoard API
|
||||
|
||||
1. Start with [API Reference](developer/api-reference.md)
|
||||
2. Explore [Collections API](developer/collections-api.md)
|
||||
2. Explore [Collections API](developer/api/collections/list_collections.md)
|
||||
3. Review [WebSocket API](developer/websocket-api.md)
|
||||
4. Test with [bruno/](../bruno/) collections
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ package docs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
@@ -46,10 +48,37 @@ func (h *HTTPHandler) currentUser(c *echo.Context) templates.User {
|
||||
return templates.User{Theme: "tokyo-night"}
|
||||
}
|
||||
|
||||
// docsNavCookie is the browser-saved collapse state of the docs sidebar
|
||||
// (set by web/src/docs.ts on every toggle). Strictly-necessary UI state:
|
||||
// first-party, no identifiers, lets the server render the sidebar in the
|
||||
// visitor's chosen state so there is no post-paint jump.
|
||||
const docsNavCookie = "docs_nav"
|
||||
|
||||
// docsNavOverrides reads the saved collapse state from the cookie.
|
||||
// Keys are the sidebar's data-key values ("Section", "Section / Group");
|
||||
// values are the collapsed flag. Malformed cookies are ignored — the
|
||||
// server-rendered defaults win.
|
||||
func docsNavOverrides(c *echo.Context) map[string]bool {
|
||||
cookie, err := c.Cookie(docsNavCookie)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return nil
|
||||
}
|
||||
raw, err := url.QueryUnescape(cookie.Value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var overrides map[string]bool
|
||||
if err := json.Unmarshal([]byte(raw), &overrides); err != nil {
|
||||
return nil
|
||||
}
|
||||
return overrides
|
||||
}
|
||||
|
||||
// renderDocPage renders a document inside the docs layout.
|
||||
func (h *HTTPHandler) renderDocPage(c *echo.Context, doc *templates.Document) error {
|
||||
currentPath := c.Request().URL.Path
|
||||
nav := h.docs.BuildNavigation(currentPath)
|
||||
ApplyNavOverrides(nav, docsNavOverrides(c))
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := templates.DocsLayout(*nav, *doc, h.currentUser(c), currentPath).Render(c.Request().Context(), &buf); err != nil {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDocsRelativeLinks keeps the developer docs tree internally navigable:
|
||||
// every relative markdown link must point at a file that exists. The
|
||||
// api-reference overview once shipped 20+ directory-style links
|
||||
// (authentication/ etc.) that 404'd — this gate makes that class of rot
|
||||
// fail make test. (docs/user is excluded: end-user prose has its own pass.)
|
||||
//
|
||||
// Rules:
|
||||
// - http(s):, mailto:, and pure #anchors are skipped
|
||||
// - every other target must end in .md and resolve (relative to the
|
||||
// linking file) to an existing file inside the tree
|
||||
// - directory-style targets ("area/") are the exact bug this test exists
|
||||
// for and always fail
|
||||
var docsLinkRe = regexp.MustCompile(`\[[^\]]*\]\(([^)\s]+)\)`)
|
||||
|
||||
func TestDocsRelativeLinks(t *testing.T) {
|
||||
// Walk developer docs; escape-check against the whole docs tree —
|
||||
// cross-area links into docs/user are legitimate (same site).
|
||||
devRoot, err := filepath.Abs("../../docs/developer")
|
||||
if err != nil {
|
||||
t.Fatalf("abs developer root: %v", err)
|
||||
}
|
||||
treeRoot, err := filepath.Abs("../../docs")
|
||||
if err != nil {
|
||||
t.Fatalf("abs docs root: %v", err)
|
||||
}
|
||||
err = filepath.WalkDir(devRoot, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") {
|
||||
return err
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(docsRoot, path)
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range docsLinkRe.FindAllStringSubmatch(string(raw), -1) {
|
||||
target := m[1]
|
||||
|
||||
if strings.HasPrefix(target, "http://") ||
|
||||
strings.HasPrefix(target, "https://") ||
|
||||
strings.HasPrefix(target, "mailto:") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(target, "#") {
|
||||
continue // in-page anchor
|
||||
}
|
||||
|
||||
// strip a trailing fragment for file resolution
|
||||
fileTarget := target
|
||||
if i := strings.Index(fileTarget, "#"); i >= 0 {
|
||||
fileTarget = fileTarget[:i]
|
||||
}
|
||||
if fileTarget == "" {
|
||||
continue // pure-anchor form already handled; empty = odd, skip
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(fileTarget, ".md") {
|
||||
t.Errorf("%s: link target %q is not a .md file — directory-style links 404 on the docs site; link the file (e.g. %q)",
|
||||
rel, target, strings.TrimSuffix(fileTarget, "/")+".md")
|
||||
continue
|
||||
}
|
||||
|
||||
resolved := filepath.Clean(filepath.Join(filepath.Dir(absPath), fileTarget))
|
||||
if !strings.HasPrefix(resolved, treeRoot+string(filepath.Separator)) {
|
||||
t.Errorf("%s: link target %q escapes the docs tree", rel, target)
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(resolved); err != nil {
|
||||
t.Errorf("%s: link target %q does not resolve to an existing file (%s)", rel, target, resolved)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk docs: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -323,6 +323,30 @@ func (h *DocsHandler) BuildNavigation(currentPath string) *templates.Navigation
|
||||
return nav
|
||||
}
|
||||
|
||||
// ApplyNavOverrides merges the browser-saved collapse state (docs_nav
|
||||
// cookie) into the nav so the sidebar first-paints in the visitor's chosen
|
||||
// state — no post-paint JS correction, no jumping. Keys mirror the
|
||||
// data-key attributes DocsSidebar renders: section titles, and
|
||||
// "Section / Group" for groups. Entries for unknown keys (renamed or
|
||||
// removed sections) are ignored; keys absent from the map keep the
|
||||
// path-derived default.
|
||||
func ApplyNavOverrides(nav *templates.Navigation, overrides map[string]bool) {
|
||||
if len(overrides) == 0 {
|
||||
return
|
||||
}
|
||||
for si := range nav.Sections {
|
||||
s := &nav.Sections[si]
|
||||
if v, ok := overrides[s.Title]; ok {
|
||||
s.Collapsed = v
|
||||
}
|
||||
for gi := range s.Groups {
|
||||
if v, ok := overrides[s.Title+" / "+s.Groups[gi].Title]; ok {
|
||||
s.Groups[gi].Collapsed = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// containsURL reports whether currentPath is one of the given doc URLs.
|
||||
func containsURL(currentPath string, urls []string) bool {
|
||||
for _, u := range urls {
|
||||
|
||||
@@ -23,7 +23,7 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body x-data="docs" x-init="restoreDocsNav(); initializeSearch(); initializeCodeCopyButtons()" class={ "theme-" + user.Theme + " page-docs font-sans antialiased" }>
|
||||
<body x-data="docs" x-init="initializeSearch(); initializeCodeCopyButtons()" class={ "theme-" + user.Theme + " page-docs font-sans antialiased" }>
|
||||
@DocsTopbar(user)
|
||||
<!-- Mobile drawer backdrop -->
|
||||
<div id="docs-overlay" class="hidden fixed inset-0 z-40 lg:hidden" style="background: var(--surface-overlay);" onclick="document.getElementById('docs-sidebar').classList.add('-translate-x-full'); this.classList.add('hidden')"></div>
|
||||
|
||||
@@ -71,7 +71,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<body x-data=\"docs\" x-init=\"restoreDocsNav(); initializeSearch(); initializeCodeCopyButtons()\" class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<body x-data=\"docs\" x-init=\"initializeSearch(); initializeCodeCopyButtons()\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+21
-39
@@ -16,39 +16,38 @@ function toggleSidebar(): void {
|
||||
}
|
||||
|
||||
// Docs sidebar collapse persistence. Rendering ownership: the server
|
||||
// renders what is expanded on FIRST paint (navigation.go derives it from
|
||||
// the current path); after that, open/closed is client-local state and
|
||||
// survives navigation via localStorage. Keys are the data-key attributes
|
||||
// rendered by DocsSidebar ("Section" / "Section / Group").
|
||||
// renders what is expanded (navigation defaults + docs_nav cookie
|
||||
// overrides merged in internal/docs), so first paint is already correct —
|
||||
// no post-paint correction, no jumping. This module only records intent:
|
||||
// every toggle rewrites the cookie with the full current state.
|
||||
|
||||
const DOCS_NAV_STORAGE_KEY = "docs-nav-state";
|
||||
const DOCS_NAV_COOKIE = "docs_nav";
|
||||
|
||||
type DocsNavState = Record<string, boolean>;
|
||||
|
||||
function readDocsNavState(): DocsNavState {
|
||||
try {
|
||||
const raw = localStorage.getItem(DOCS_NAV_STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as DocsNavState;
|
||||
}
|
||||
} catch {
|
||||
// Corrupt state falls back to the server-rendered defaults.
|
||||
}
|
||||
return {};
|
||||
// collectDocsNavState reads the current collapsed flag of every
|
||||
// collapsible in the sidebar straight from the DOM.
|
||||
function collectDocsNavState(): DocsNavState {
|
||||
const state: DocsNavState = {};
|
||||
document.querySelectorAll<HTMLElement>("[data-key][data-collapsed]").forEach((el) => {
|
||||
const key = el.getAttribute("data-key");
|
||||
if (key) state[key] = el.getAttribute("data-collapsed") === "true";
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
function writeDocsNavState(state: DocsNavState): void {
|
||||
function persistDocsNavCookie(state: DocsNavState): void {
|
||||
try {
|
||||
localStorage.setItem(DOCS_NAV_STORAGE_KEY, JSON.stringify(state));
|
||||
// Strictly-necessary UI state: first-party, no identifiers, one year.
|
||||
document.cookie = `${DOCS_NAV_COOKIE}=${encodeURIComponent(JSON.stringify(state))}; path=/; max-age=31536000; SameSite=Lax`;
|
||||
} catch {
|
||||
// Storage full/blocked: persistence is best-effort.
|
||||
// Storage blocked: the sidebar still works, state just doesn't persist.
|
||||
}
|
||||
}
|
||||
|
||||
function applyCollapsed(items: HTMLElement, collapsed: boolean): void {
|
||||
items.classList.toggle("hidden", collapsed);
|
||||
// The data-collapsed attribute is the single source of truth — the
|
||||
// stylesheet hides on it, and the arrow is rotated relative to it.
|
||||
items.setAttribute("data-collapsed", collapsed ? "true" : "false");
|
||||
const arrow = items.previousElementSibling?.querySelector<HTMLElement>(".section-arrow");
|
||||
if (arrow) {
|
||||
@@ -56,16 +55,6 @@ function applyCollapsed(items: HTMLElement, collapsed: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
function restoreDocsNav(): void {
|
||||
const state = readDocsNavState();
|
||||
document.querySelectorAll<HTMLElement>("[data-key][data-collapsed]").forEach((items) => {
|
||||
const key = items.getAttribute("data-key");
|
||||
if (key && key in state) {
|
||||
applyCollapsed(items, state[key]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCollapsible(header: HTMLElement): void {
|
||||
const items = header.nextElementSibling as HTMLElement | null;
|
||||
if (!items) return;
|
||||
@@ -73,13 +62,7 @@ function toggleCollapsible(header: HTMLElement): void {
|
||||
const isCollapsed = items.getAttribute("data-collapsed") === "true";
|
||||
|
||||
applyCollapsed(items, !isCollapsed);
|
||||
|
||||
const key = items.getAttribute("data-key");
|
||||
if (key) {
|
||||
const state = readDocsNavState();
|
||||
state[key] = !isCollapsed;
|
||||
writeDocsNavState(state);
|
||||
}
|
||||
persistDocsNavCookie(collectDocsNavState());
|
||||
}
|
||||
|
||||
let docsSearchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -169,7 +152,6 @@ function initializeCodeCopyButtons(): void {
|
||||
Alpine.data("docs", () => ({
|
||||
toggleSidebar,
|
||||
toggleCollapsible,
|
||||
restoreDocsNav,
|
||||
initializeSearch: initializeDocsSearch,
|
||||
initializeCodeCopyButtons,
|
||||
}));
|
||||
@@ -2105,3 +2105,11 @@
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Docs sidebar: the data-collapsed attribute is the single source of truth
|
||||
for section/group visibility. The server renders it (navigation defaults
|
||||
+ docs_nav cookie overrides); toggleCollapsible updates it in place. */
|
||||
.nav-items[data-collapsed="true"],
|
||||
.nav-group-items[data-collapsed="true"] {
|
||||
display: none;
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
|
||||
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
||||
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
||||
Reference in new issue
Block a user