Compare commits

..
56 Commits
Author SHA1 Message Date
John O'Keefe f6a73ab5aa feat(unlinked-books): dismiss (ignore) or delete queue entries
Release / build-and-push (push) Successful in 3m15s
Unidentified sync files used to be stuck: every KOReader re-sync
re-queues them, and there was no way to say "this isn't a book" or "I
don't want this one on the server".

Ignore is the durable dismiss: a new ignored_at column hides the row
from the page list, dashboard badge, and per-device API while the sync
keeps refreshing its metadata (the upsert never clears the column).
A collapsible "Ignored" section lists dismissed rows with Restore and
Delete so a misclick is recoverable. Delete is the hard exit — the row
is gone, and honestly re-queued if the device syncs that file again
(the confirm dialog says so).

Actions are page-scoped SSR routes: htmx swaps in the re-rendered
section so counts and buckets can never drift from the DB; ownership
is row → device → user (admins may act on any row). Integration tests
pin the lifecycle (hide everywhere, survive the sync upsert, restore,
delete), foreign-user 403s, and the render surface.
2026-10-26 14:58:50 -04:00
John O'Keefe afbf1029a9 feat(web): hold a Screen Wake Lock while reading or listening
Release / build-and-push (push) Successful in 3m12s
New wake-lock.ts engine (sanctioned island, dependency-injected for
tests): feature-detected navigator.wakeLock, silent no-op when
unsupported or denied, and the spec-mandated force-release-on-hide →
re-acquire-on-visible loop handled via the sentinel release event.
The player acquires on play and releases on pause (sleep-timer fades
ride the same path); the reader holds the lock for the session. Both
gate on the keep_screen_awake preference — data-keep-awake on the
player root, keepScreenAwake in readerInitExpr.

Hidden-tab playback intentionally does not keep the screen awake:
the spec forbids screen wake locks for hidden documents.
2026-10-26 13:59:37 -04:00
John O'Keefe c645b8e207 feat(profile): keep_screen_awake user preference (default on)
New users column surfaced as an On/Off dropdown in the profile form.
The form and API carry "true"/"false" strings with the theme-style
""-guard, so JSON clients that omit the field can't clobber it;
anything else is rejected. GET/PUT /api/auth/profile expose
keep_screen_awake alongside the other profile fields, and the reader
route threads the preference into its template metadata.

Integration tests pin the schema default, both values, omission
semantics, and rejection — dedicated test user, never testuser.
2026-10-26 13:59:32 -04:00
John O'Keefe bdb9132b85 chore(auth): drop dead UpdateProfileRequest; rename shared struct
The combined profile handler (self-edit PUT /api/auth/profile and
admin PUT /api/auth/profile/:id) binds one struct for both paths —
and it's the admin-named one. Its twin UpdateProfileRequest had zero
references left. Delete it and rename AdminUpdateUserRequest to
ProfileUpdateRequest so the name matches who actually binds it; the
comments describing field semantics now stand on their own instead of
pointing at the deleted twin. Wire format (json/form tags) unchanged;
docs schema registration follows the rename.
2026-10-26 13:59:22 -04:00
John O'Keefe 649f821850 docs nav: correct casing of 'Searching and Filtering' sidebar label
Release / build-and-push (push) Successful in 3m50s
docTitleOverrides now pins the label with lowercase 'and' (the naive
titleCase fallback capitalized every word); only the sidebar label was
affected — the page h1 comes from the markdown heading and was already
correct. Test pin added to TestDocTitle.
2026-10-26 08:26:53 -04:00
John O'Keefe 4681e98766 CSP tripwire: source-scan test + AGENTS.md rule; drop last native onchange
templates/csp_source_test.go scans .templ sources and fails the build on
the three authoring patterns hash-mode CSP refuses silently: native on*=
handler attributes (quote and templ brace forms), inline <script>
content outside the sanctioned theme_flash_guard.templ, and htmx's
eval-dependent bindings (hx-on*, js:-prefixed hx-vals — 'unsafe-eval' is
not granted). Verified to fire on all four violation classes via a
scratch probe file.

The dry run immediately caught a live leftover: unlinked_books.templ's
onchange="updateSelectedCount()" — CSP-refused since the hash flip.
Removed; the count already updates through the document-level delegated
change listener that x-init="setupEventDelegation()" registers.

AGENTS.md gains a "CSP discipline" ALWAYS-apply section documenting the
hash allow-list, the sanctioned-guard invariant, the replacement
patterns, and the tripwire test.

Verified: go build/vet, templates+middleware+docs tests green; /unlinked-
books loads with zero console violations (no fixture rows to exercise the
checkbox; the delegated path is unchanged code).
2026-10-26 08:17:49 -04:00
John O'Keefe 56dbca37d0 docs nav: move Administration above Devices in sidebar order 2026-10-25 21:15:47 -04:00
John O'Keefe 27f42bf703 docs nav: promote Devices and Administration out of User Guide
Follows the developer/api → API Reference carve-out: placement-only, no
file paths or URLs change. placeDoc routes user/devices/* to a new
top-level "Devices" section (icon: device) and user/admin-* to a new
top-level "Administration" section (icon: shield); User Guide keeps its
direct guides with no groups. Sidebar order: Getting Started, User
Guide, Devices, Administration, API Reference, Developer, Operations,
Contributing.

Verified live: on kobo-setup the Devices section opens with the three
device guides; on admin-guide Administration opens with both admin
guides; on user/dashboard User Guide opens as before. Tests
(TestPlaceDoc, TestBuildNavigation) updated to pin the new placement.
2026-10-25 21:10:53 -04:00
John O'Keefe fb95b6d400 CSP: replace 'unsafe-inline' with a content hash for the theme flash guard
script-src now allow-lists the guard by sha256 of its rendered bytes
(middleware.ScriptHashThemeFlashGuard) instead of 'unsafe-inline' — the
guard is the app's only inline <script>. security_headers_test.go renders
templates.ThemeFlashGuard() and fails with the replacement hash if the
script text ever drifts.

Executing anti-XSS strict CSP surfaced three latent violations the
eval-removal sweep couldn't see (hash mode is stricter than eval mode,
because inline event handler ATTRIBUTES now count against script-src):

1. admin_archived / admin_library: 15 native onclick=/onsubmit= handlers
   (modal open/close, folder-browser picker, purge button) converted to
   Alpine @click/@submit component methods — openCreateLibraryModal,
   hideModal, pickBrowsePath added to the admin component;
   purgeArchivedItems promoted from its window global into the component
   and the (window as any) exposure removed.

2. dashboard/docs: onclick handlers converted — dashboard's
   event.stopPropagation() → @click.stop; docs' sidebar/search closers →
   closeDocsSidebar/closeSearchResults component methods.

3. 8 onerror= image-fallback attributes (cover placeholders app-wide +
   audiobook cover hide) replaced by data-fallback attributes handled by
   one delegated capture-phase listener in web/src/image_fallback.ts —
   works for htmx-swapped content without re-binding.

Verified: theme guard applies a saved theme on public pages under the
hash CSP; 14-page authenticated sweep clean; admin pages clean under the
promoted admin; admin-library modal open/close bindings exercised;
readers render error-free; go build/vet/middleware-test, tsc, vp
check/test (146), build:ts all green.
2026-10-25 20:54:09 -04:00
John O'Keefe 6a598786e9 fix(reader): highlight color picker + window-aware note popover under CSP build
- Highlight color dots carried a nested-ternary + comma-sequence
  expression that the CSP-safe Alpine evaluator cannot parse — the
  buttons were dead. Logic moved to pickHighlightColor(color): sets
  the color, saves changes for edit-mode, creates the highlight for
  create-mode.
- The selection popover (and its note area) rendered above the
  selection anchor with a fixed size, so on small windows or
  top-of-page selections it escaped the viewport. Placement is now
  window-aware: show-time clamping via estimated popover size, and a
  measure-and-shift pass when the note area widens the popover (the
  post-open adjustment runs well after the enter transition; mutating
  during it is what breaks compositor hit-testing).
2026-10-25 19:32:23 -04:00
John O'Keefe 3b66b981be fix(devices): copy buttons via component method (copyFromId) under CSP build 2026-10-25 18:14:44 -04:00
John O'Keefe 6d01bd7971 fix(web): convert remaining JS-global bindings for the CSP build; drop unsafe-eval
The strict CSP sweep surfaced a final class of bindings on the audio
reader and book detail pages that referenced browser globals
(Number, String, Math, window, document) — identifiers the CSP-safe
evaluator resolves only from component scope:

- audio reader: scrub input, volume slider/input, speed+skip active
  styles, genre tick styles, and the default-speed select now route
  through component methods (onScrubInput, setVolumeFromEvent,
  applyDefaultSpeedFromSelect, volumeLabel, railTickStyle,
  isSkipActive, isSpeedActive, isDefaultSpeed)
- book detail: cover-upload click and banner edit validation moved to
  component methods (triggerCoverUpload, bannerEditHasText)
- admin: Refresh-to-View-Books reload moved to reloadPage()
- reader: note edit validation moved to noteEditHasText()

CSP header: frame-src 'self' blob: (foliate renders book content in
a blob: frame) and blob: on font-src/img-src (EPUB-embedded fonts
and images load as blob: URLs inside that frame). 'unsafe-eval' is
gone: script-src is 'self' plus 'unsafe-inline' for the pre-paint
theme flash guard.

Also: header openPanel promoted to the root component (one open
panel across all sidebar panels, persisted in init), with the
sidebar shell reduced to themeMode state + methods; fmtTime restored
to the audioReader component (ripped by the unexport pass — time
displays were dead on the audio reader).
2026-10-25 17:39:30 -04:00
John O'Keefe 09545ca5e1 fix(web): complete the CSP-build migration across remaining surfaces
- audio reader: scrub/volume/speed/genre-tick bindings converted to
  component methods (onScrubInput, setVolumeFromEvent,
  applyDefaultSpeedFromSelect, volumeLabel, railTickStyle,
  isSkipActive/isSpeedActive) — these used Number/Math/String globals
  that the CSP evaluator cannot resolve
- fmtTime restored to the audioReader component: the unexport pass
  had ripped it from the Alpine.data object (time displays were dead)
- book detail: cover-upload click and banner edit validation moved to
  component methods (triggerCoverUpload, bannerEditHasText)
- admin/docs/index components: init() hooks for their x-init chains
- CSP header: 'unsafe-eval' dropped — the CSP build needs no eval
- e2e spec: replace test.info().annotate (unavailable in this
  Playwright version) with console logging
2026-10-25 17:22:40 -04:00
John O'Keefe eb57b7208b feat(web): switch to the Alpine CSP build; drop 'unsafe-eval' from CSP
Standard Alpine evaluates every attribute expression through
new Function(), which a strict Content-Security-Policy cannot allow.
Swap to the official CSP-safe build (@alpinejs/csp), whose parser
evaluates expressions against registered component scope without
eval. script-src now carries only 'self' and 'unsafe-inline' (the
latter still required by the pre-paint theme flash guard).

Bindings rewritten out of the unsupported expression shapes:
- 21 multi-statement sequences across admin, audio_reader, docs,
  header, index, and reader became single component methods
  (init/loadWatchStatus hooks, commitScrub, pickSkip, pickSpeed,
  pickDefaultSpeed, chooseSleepOption, chooseSleepClockMode,
  toggleFxInvert, openPageThumbs, submitNote, clearSearch,
  pickReadingTheme)
- the header sidebar's inline x-data (openPanel/themeMode IIFE)
  became a registered sidebarShell component; openPanel itself moved
  to the header root so every sidebar panel shares one open state
- the appearance-panel  moved into the component init()
- header init() now also seeds openPanel from localStorage (or the
  admin section on /admin pages)

Alpine magics (, , , ) remain available —
the CSP evaluator injects them into scope.

The Playwright suite covers the converted surfaces end to end.
2026-10-25 16:58:28 -04:00
John O'Keefe a730a62280 refactor(ui): promote generic publication fields to their own editor section
Age Rating, Imprint, Scan Information, and Metadata Notes are
format-agnostic columns (the schema marks age rating 'applies to all
formats'; imprint examples span comics and books) — but they rendered
inside the Comic/Manga section, showing a manga dropdown on
audiobooks and hiding useful fields behind comic gating.

Split the editor into:
- Publication Details: Age Rating, Imprint, Scan Information,
  Metadata Notes — always visible for every media type (empty for
  non-comics sources: no standard metadata carries these outside
  ComicInfo.xml; editable and persistent for all).
- Comic/Manga (gated by ShowComicMetadata, unchanged): Manga Type,
  Reading Direction, Story Arc, Black & White.

Pure presentation move — identical form field names, no handler or
schema changes.
2026-10-25 12:50:37 -04:00
John O'Keefe d41c3e4774 fix(ui): hide Comic/Manga editor section for non-comics media
The metadata editor's Comic/Manga section (Manga Type, Reading
Direction, Age Rating, Story Arc, Imprint, Scan Info, Metadata
Notes, B&W) rendered for every item — an audiobook showed a manga
dropdown, which is nonsense.

MediaDetail now carries LibraryTypeName (resolved via
GetLibraryType in the /media/:uuid handler) and a
ShowComicMetadata() gate: comics and manga libraries always show the
section; other media only when the scanner recorded comic-style
values (fixed-layout manga-style EPUBs get those scanner defaults
deliberately — when present they stay editable).
2026-10-25 12:08:26 -04:00
John O'Keefe ab2a5568ae fix(scanner): split comma-joined ABS sidecar genres into separate tags
Audiobookshelf-style exports join genres into one comma-string
(["Epic, Action & Adventure, Military, Sword & Sorcery"]). The
smart-genre picker already split candidates for the genre column,
but the tags assembly used the raw unsplit array — so the whole
string landed as ONE element of media_items.tags and rendered as a
single tag everywhere.

Use pickSmartGenre's split candidates for tags too, exactly as the
function's own comment documents ('split parts included stay in
tags'). Commas inside a single subject heading remain legal within
one tag (EPUB dc:subject path unchanged).
2026-10-25 12:05:14 -04:00
John O'Keefe ecfd7276ab test(e2e): add Playwright system smoke and regression suite
Covers: health, auth guards, register/login/token flow, every
protected page render with per-page uncaught-error capture, devices
modal wiring, stats tabs, docs pages, theme switch persistence,
reader links against real library content, security header presence,
and explicit regression assertions for the functions removed in the
dead-code cycle (Clear Queue button, queue endpoints).
2026-10-25 11:24:25 -04:00
John O'Keefe b6eba66801 fix(web): restore Alpine theme members and allow eval for Alpine expressions
Two regressions from the cleanup cycle, caught in E2E:

1. The unexport pass stripped loadTheme, loadUserTheme, and
   updateThemeIndicators from the Alpine.data("theme") registration —
   the member-removal pass could not tell export-block lines from
   Alpine component object lines. Theme dropdowns on docs and
   non-app pages stopped reflecting the active theme. Members
   restored (functions were kept, only their scope registration was
   lost).

2. The new Content-Security-Policy shipped script-src without
   'unsafe-eval'. Alpine evaluates every @click / x-init / x-show
   expression through new Function(), so the CSP silently disabled
   ALL Alpine interactivity app-wide: sidebar categories stuck open,
   modals rendering unhidden and unresponsive, dropdowns inert.
   script-src now includes 'unsafe-eval' with a comment explaining
   the Alpine requirement.
2026-10-25 11:24:25 -04:00
John O'Keefe 4ab6a64bd8 docs: drop stale interactive API Explorer references
The explorer page was never routed and its client module is removed
in the preceding refactor; the endpoint docs render examples
statically.
2026-10-25 11:06:27 -04:00
John O'Keefe dc0e3573a6 chore(web): add knip and remove unused npm dependencies
Drops highlight.js, lunr, autoprefixer, and @types/markdown-it —
all verified unreferenced (markdown-it ships its own types). Adds
knip.json configured for the templ/htmx architecture: main.ts and
reader/reader.ts entries, vitest tests as entry points, and an
ignore list for the ten modules whose exports are bound by templ
Alpine attributes — string bindings that static analysis cannot see.
2026-10-25 11:06:27 -04:00
John O'Keefe a716b386a1 fix: wire security headers, graceful shutdown, and settings invalidation
Lifecycle (internal/app + main.go):
- App.OnShutdown hooks run after the HTTP server drains, in
  registration order: Worker.Shutdown (background jobs now drain
  instead of dying on SIGTERM), DeviceAuthMiddleware.Stop (releases
  the rate-limiter ticker), Handler.CloseScanner (fsnotify watcher
  and scan log files)

Security headers:
- SecurityHeadersMiddleware (survivor of middleware/security.go,
  renamed security_headers.go) is now on the echo stack: nosniff,
  frame-deny, HSTS, CSP (unsafe-inline required by the sanctioned
  theme flash guard + Alpine), referrer and permissions policy

Scanner settings cache:
- settings saves (UpdateScanSettings + ApplySetting for the three
  scan keys) now invalidate MediaScanner's 30s settings cache via
  SystemSettingsHandler.SetScanSettingsChanged, so changes apply on
  the next poll instead of after the TTL
- MediaScanner.Close closes the scan logger, fixing a file-descriptor
  leak on every per-item rescan (fresh scanners never closed logs)

Client fix:
- custom-section-builder's add/remove-selection buttons used inline
  onclick attributes referencing module-scoped functions, which are
  not global in a bundled ES module: the buttons threw ReferenceError.
  Functions restored and exposed on window via declare global

Also fixes a pre-existing vet error (saveCoverImage copied
echo.Context by value, mutex included) and drops the per-key
SettingsCache.InvalidateKey variant left callerless by the
full-flush invalidation wiring.
2026-10-25 11:06:26 -04:00
John O'Keefe 8631aa00bf refactor: remove dead code across server, templ pages, and client modules
Deadcode-verified removals (RTA reachability from main, cross-checked
with -test and textual xrefs):

Go server:
- whole files: middleware/transaction.go, middleware/error_handler.go,
  middleware/password_validator.go (live registration machinery kept),
  handlers/sync.go, sync/offline.go
- unrouted endpoints: auth UpdateUsername/UpdateEmail, timezone
  settings handler, OPDS RegisterOPDS, Kobo LibrarySync handler,
  ReaderHandler.ShowReader, JSON-data twins on collections/library
- duplicate book-matching handlers (book_matching.go Handler twins,
  matching.go MatchingHandler twins), superseded helpers
  (router.parseUUID, handlers.ValidateDeviceToken, normalizePath,
  userExists, generateLockID)

Templ/client:
- never-routed pages: api_explorer.templ, queue.templ and their
  client modules api-explorer.ts, api-explorer-docs.ts, queue.ts
- fully orphaned wrapper modules events.ts, dom.ts
- broken clearSyncQueue (called a nonexistent endpoint) and the
  hidden, never-populated sync-queue section on the devices page
- 166+ unused exports demoted to private (knip-verified); ~85 truly
  dead declarations removed via tsc noUnusedLocals fixpoint
- stale 'interactive API Explorer' claims dropped from docs

The docs drift-gate package (internal/docs), templ-bound modules
(dashboard, stats, header, index, login, profile*, bookshelf,
collections, toast-error), sqlc-generated code, and web/static are
intentionally untouched.
2026-10-25 11:06:08 -04:00
John O'Keefe 8733e83f1f style: apply gofmt across Go sources
Repo-wide gofmt sweep: const-block alignment, doc-comment spacing,
statement-after-brace fix in media.go, trailing blank lines. No
semantic changes — whitespace-insensitive diff is empty for every
file in this commit.
2026-10-25 11:05:37 -04:00
John O'Keefe dd2ab4ea96 docs(ui): center the active nav link in the sidebar instead of edge-clamping
The clamp put deep-linked highlights flush against the sidebar's bottom
edge. Centering is simpler AND handles the max-scroll case for free:
scrollTop carries the item's center to the scrollport center, and the
browser clamps to the scroll extent — items near the nav's top/bottom
land as centered as the content allows (scroll pinned fully up/down),
which is the requested fallback.
2026-10-24 22:04:06 -04:00
John O'Keefe da0bae94bb docs(ui): scroll the sidebar so the active (bold) link is in view on load
Deep-linking into a low doc (e.g. ratings/get_ratings from the overview
page) opened the right menus but sat the highlight below the sidebar's
visible scroll area. scrollSidebarToActive() runs at docs Alpine init:
finds the link for the current path, and if it's outside the sidebar's
scrollport, moves ONLY the sidebar's own scrollTop to clamp it into the
nearest edge — never the page scroll. Skips the off-canvas mobile
drawer. (Same behavior as Docusaurus/MkDocs on deep links.)

Live: x-init wired on docs pages, function in the shipped bundle,
make test green, 146 vitests.
2026-10-24 21:57:03 -04:00
John O'Keefe a1ac5b1541 docs(ui): canonicalize .md URLs — sidebar now opens when arriving from overview links
Root cause of 'clicked from overview, bold but menus closed': cross-page
markdown links point at area/file.md; the handler happily loads .md
URLs, but the sidebar keys on the URL path — contains-URL expansion and
the active-link styling compare against extension-less nav URLs, so a
.md path rendered the page with its menu closed and the current item
unhighlighted. (My curl checks had used clean URLs, which is why it
worked in testing but not from the overview page.)

Fix: ShowDocumentation 301-redirects .md paths to the clean canonical
form (query preserved; browsers re-attach fragments), and
renderDocPage normalizes the path for nav expansion/grouping as a belt-
and-braces. Live-verified: .../get_ratings.md -> 301 ->
clean page with both containing menus open and the get_ratings link
carrying the active class.
2026-10-24 21:54:06 -04:00
John O'Keefe 73e8cdef4b docs(ui): drop sidebar saved-state — server defaults already ARE the standard docs behavior
Simplification per spec: the menu tracks state through the URL alone.
Land on /docs: fully closed, headers only (an explicit home rule now
overrides the contains-URL rule, since the root overview doc lives in
Getting Started and would otherwise auto-open it). Land on any doc page:
its containing section + group render open at first paint, current link
bold — no JS, no jump, arrows consistent. Navigate elsewhere: old
closes, new opens.

Deleted: docs_nav cookie const/parse, ApplyNavOverrides, cookie write +
collect/persist helpers in web/src/docs.ts, data-key attributes in the
sidebar templ, and the first-section-open fallback (docs home used to
force-open the first section — spec'd as closed).

toggleCollapsible remains for in-page peeking; purely ephemeral.
The [data-collapsed=true] CSS from the previous round stays — it is what
makes the server-rendered state actually hide sections at paint (the
original bug was that this CSS never existed).

Tests: TestBuildNavigationNothingCurrent now asserts zero sections open
on unmatched paths; TestBuildNavigation asserts Getting Started closed
on a device page (no site-wide pin). make test green; 146 vitests; live
verified: home 0 expanded, doc pages exactly 2 (section+group).
2026-10-24 21:17:55 -04:00
John O'Keefe a8f8c4def9 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.
2026-10-24 20:20:07 -04:00
John O'Keefe f1a6de7cd7 ui: fix admin/docs theme flash + persist sidebar open/closed state
Theme flash (admin pages, docs, stats, progress — anywhere SSR'd):
the JWT middlewares built the context user WITHOUT the Theme column,
so every page rendering theme-{user.Theme} from c.Get("user") painted
an empty class (:root tokyo-night default) and only settled on the real
theme after initializeTheme -> loadUserTheme round-tripped the server.
Both middlewares now carry liveUser.Theme into the context user;
verified live: /admin /stats /progress /docs /docs-deep /dashboard
/collections all SSR theme-hackerman.

Defense in depth for the same bug: loadTheme() no longer re-applies
localStorage over a server-rendered body theme (applyTheme records even
the default, so any signed-out visit baked tokyo-night into localStorage
and every signed-in page flipped to it post-paint). Public auth/landing
bodies dropped their hardcoded theme classes so ThemeFlashGuard's
pre-paint html class actually governs them; docs theme <select> now
syncs from the body class and re-syncs after the profile fetch.

Sidebar state (the unpleasant part): the app sidebar's openPanel was
reinitialized from the pathname on every load; it now restores from
localStorage ('sidebar-open-panel') with the pathname default as
fallback and -persists every toggle. Docs sidebar sections/groups
carry data-key attributes and toggleCollapsible persists collapsed
state per key ('docs-nav-state'); restoreDocsNav() applies it at init —
server-rendered defaults still govern first visit.

make test green; rendering ownership OK (no new inline scripts); 146
vitests pass; live-verified SSR classes on 7 pages.
2026-10-24 19:44:34 -04:00
John O'Keefe 983980d07a bruno+docs: status asserts on generated mirrors, OpenAPI spec, safe negative scenarios
Three follow-ups from the smoke run, all additive:

1. Status asserts: bruno_gen.go parses each doc's documented success
   status (## Response (2NN), 'Success is **201**', '**204 No Content**')
   and emits a runtime tests script (expect(res.status).to.eql(N)) on
   148 of 191 mirrors. GETs default to 200 (uniform across this API);
   mutating verbs stay assert-less unless documented — no guessed
   asserts. Verified live: asserts execute and fail loudly (deliberate
   999 probe), pass on real responses.

2. OpenAPI: the same -update run now also writes
   docs/developer/openapi.yaml (141 paths, tags from docs areas,
   security schemes per auth label, request examples literal, no
   invented schemas — the docs site remains the shape reference).
   Verify mode fails when the file is missing.

3. Scenarios: 7 safe negative scenarios rebuilt (auth registration
   matrix 409/403/400, invalid-uuid 400, unknown 404 x2, invalid range
   400) — all no-mutation, repeatable, run via bru: 7/7 tests pass.
   These replace a fraction of the deleted broken suite; mutations-
   heavy scenarios stay out until a design for setup chains exists.

created_at RFC3339 deliberately NOT done — it is the one item that
would break things (Android parses current formats; plan reserves it
as a compat decision).bru run of mutating mirrors also proven to touch
live data (a 'To Read' collection was created and deleted in testing);
only id-less GET mirrors and negative scenarios are safe for blanket
runs.

make test green; generation idempotent (byte-identical re-runs);
no stray smoke rows in users.
2026-10-24 19:20:13 -04:00
John O'Keefe 0c45137f5b docs(users): live-smoke correction — GET /api/auth/users emits sqlc ListUsersRow, no password_hash
Curated smoke run of the generated collection against the live dev
server (37/37 after fixes): registration matrix (409 duplicate email,
403 admin-creation-with-admins-present, 400 weak password), non-admin
403s, device-token/opds 401s, collection CRUD incl. the documented 201
update quirk and 204 delete, stats routes, scanner, jobs, malformed-uuid
400s. Testuser untouched (password test uses a guaranteed-wrong current
password -> 401); the smoke user was admin-deleted in cleanup and its
token verified dead.

Correction the smoke run forced: the batch-8 'password_hash leak' was a
false alarm — my registry pin ([]database.Users) never matched the
handler, which returns []database.ListUsersRow (sqlc join with
device_count). Live response confirmed: id/email/username/theme/names/
role/max_devices/created_at/updated_at/device_count — NO hash. Doc
example, field table, and registry pin all corrected; leak note
replaced with the live-verified statement.

Also verified via the real Bruno CLI: 9 safe generated requests
(4 shown passing incl. stats/collections/devices; password-rules
public) execute against the server with collection-level auth.
Full-collection bru run deliberately not attempted (mutations in
static bodies would touch live data).
2026-10-24 18:58:39 -04:00
John O'Keefe cfcd95b25e bruno: replace Python generator with Go golden-writer in the drift test
The generator moves into the Go toolchain per repo preference:
internal/docs/bruno_gen.go emits the OpenCollection mirrors (same output
contract — gate-verified example-request bodies, env-var params,
device-token headers, login/register/refresh token-capture scripts) and
TestBrunoMirrorsDocs grows a -update flag: verification by default,
regeneration on demand:

  go test ./internal/docs -run TestBrunoMirrorsDocs -update

Generator and checker are now the same code — they cannot drift apart.
scripts/gen_bruno_from_docs.py deleted; opencollection.yml README points
at the go-test command.

Also fixed vs the Python pass: mirror placement follows the docs tree
folder (RelPath), not the area frontmatter (several docs — highlights,
notes, ratings, users, progress — carry a different area); docs blocks
emit the collection's mapping form (content + type), not a bare scalar;
JSON body sanity is checked at generation time.

Idempotent: back-to-back -update runs are byte-identical.
make test green (9 pkgs).
2026-10-24 18:47:56 -04:00
John O'Keefe acd82fff0a bruno: regenerate collection from canonical docs + permanent docs↔Bruno parity gate
WO4. The old collection was the drifted artifact AGENTS.md warned about:
bodies pre-truncated at their first {{var}}, routes renamed server-side
but not here (/api/notes/:media_id, /api/book-matching/search,
/api/collections/:id/rules/test), an orphan universal-progress suite for
routes that no longer exist.

scripts/gen_bruno_from_docs.py emits one OpenCollection request per
frontmatter'd endpoint doc (191 requests): gate-verified example-request
JSON as body, {{env}} params per the environment file, device-token
routes get an explicit Bearer {{device_token}} header, login/register/
refresh keep the token-capture after-response scripts that chain the
collection. Old 1:1 mirrors deleted (128); hand-authored NewDevDBSetup,
environments, and edge-case suites kept. 30 scenario files with
truncated bodies (the documented corruption) deleted — they could not
run.

internal/docs/drift_test.go gains TestBrunoMirrorsDocs: every
frontmatter'd doc must have a Bruno request with matching method+URL
shape — the collection can never silently drift from the docs again.
opencollection.yml README block replaced with the real run instructions.

make test green (9 pkgs, parity test included in internal/docs).
2026-10-24 18:35:51 -04:00
John O'Keefe 6fc501b35a docs(stats): Claude-side pinning — all 4 stats endpoints + UNVERIFIED sweep to zero
Stats docs written handler/service-first (summary/this-week/book-stats
new; library-usage frontmatter'd — its body was already accurate).
Registry: 4 entries on services.UserSummaryStats/WeekSummaryStats/
LibraryUsageStats/BookStats. Family-lens note: library-usage 403s an
explicit library_id outside the visible set (431fec0 rule).

UNVERIFIED sweep (7 -> 0), each resolved against the handler:
- device-usage takes NO date params; popular-books takes only ?limit
  (the 'standard analytics date-range params' claim was wrong)
- link_book media_item_id: no validate tag but service-always-written
- push_stats book-ref field set documented (uuid/sha256/title/authors)
- filter_count: confirmed ParseMediaSearchParams machinery + 400 on bad
  filter + 403 visibility
- facets values element shape pinned: {value, display?, count}
- get_config: dynamic key set IS the contract (reworded, no marker)

make test 9/9.
2026-10-24 18:25:12 -04:00
John O'Keefe e21e1facde docs(media-items,collections,koreader,admin): Beam batch 11 — final backfill
All remaining endpoint docs frontmatter'd: media-item bulk ops + audio
surface, collections book-listing/preview, koreader rating/review/stats
plugin protocol, hash-conflicts JSON list.

Scope call (Claude): resolve_hash_conflict.md DELETED — the resolve POST
renders an htmx fragment (renderResolved), a web-admin flow under rule 3;
only the GET list is a JSON API. update_media_review skipped by Beam
(correct): ratings/upsert_media_review.md already covers the PUT.

Registry: 16 entries. koreader rating/review/stats all bind
koreaderBookRef (query or body) — odd=half-star rating scale; audio track
endpoint serves raw bytes (ServeContent, Range) — response nil. Fixed
drift: deleted_annotations doc's secondary restore/purge fences
unlabeled (gate validates labeled blocks against the ONE frontmatter
route); chapters example labeled. UNVERIFIED: 1 (koreader book-ref
fields, push_stats). make test 9/9.
2026-10-24 18:21:17 -04:00
John O'Keefe 18084de7a6 docs(book-matching,collections): Beam batch 10 — 15 endpoints
WO3 sync internals resolved: the book-matching docs ARE the sync surface
(books/query, link-book, bulk/auto-link, unlinked-books, suggestions,
file-alias CRUD) + collections rule/test/bulk-assign/shelf-mapping
surface.

Dedupe resolved (Claude call): search_books.md declared
GET /api/books/match but its BODY documents POST /api/sync/books/query —
both routes exist and are distinct (query-param device protocol vs JSON
body plugin protocol). Repointed search_books.md frontmatter to the POST
route; get_book_matches.md keeps the GET.

Claude registry: 15 entries (one pre-existing link-book entry deduped).
Drift fixed by gate: auto-link request {confidence_threshold, limit} not
{device_id, threshold}; bulk-link link objects {unlinked_book_id,
media_item_id, confidence_score} not {device_id, book_id, ...};
responses {results,total,successful,failed}/{auto_linked,results}; added
missing example-responses (bare delete, remove-rule is_smart-after
semantics, bulk-assign per-book results). make test 9/9.
2026-10-24 17:59:27 -04:00
John O'Keefe 7a7b2ab82a docs(highlights,notes,opds,progress,analytics): Beam batch 9 — 13 endpoints
Highlights + notes CRUD (media-item scoped, jwt), progress delete,
reading-stats analytics, and the OPDS trio frontmatter'd. OPDS docs are
the first XML entries: example-response intentionally omitted (Atom
responses), gate contract honored via nil-Response registry pins.

Gate fix: group .Use() middleware now parsed (opds.Use(DeviceAuth...
Authenticate) previously resolved to auth 'none'). Registry: 13 entries.
Drift fixed by gate: reading-stats key names (total_reading_time_minutes,
most_active_day_of_week, daily_reading_minutes — draft invented
reading_time_hours/books_completed); notes/highlights list endpoints
return BARE arrays (no {notes:[]}/{highlights:[]} wrapper); create-note
request has no percentage_location/epubcfi_location. New
/tmp/merge_arrays.py handles array-example merges.

make test 9/9.
2026-10-24 17:47:07 -04:00
John O'Keefe cc5677eb17 docs(auth,users,devices,progress,matching): Beam batch 8 — WO5 backfill complete
14 endpoints backfilled: devices pending/approve/reject; users
password/profile/profile-delete self-service (admin variants in prose);
admin list_users + update_user_max_devices; progress history; book
matching; new password-rules doc.

Gate fix: authLabel missed JWT middleware written inline as
createJWTMiddleware(cfg) (uppercase 'JWT') — reported auth 'none' for
/api/auth self-service routes and would have for any wrapper-inlined
group. Labeled 'JWTMiddleware' + fallthrough 'jwt' case.

Claude registry: 12 entries. Drift fixed by gate: list_users on-device —
handler returns raw []database.Users (bare array, full model incl.
password_hash — real leak the API emits; pinned + documented; earlier
draft had invented {users,total,limit,offset} wrapper). max-devices
response is {message} only. books/match is GET /api/books/match
{matches, action}. progress history sessions = ReadingHistory rows
(pgtype fields). make test 9/9.
2026-10-24 17:33:32 -04:00
John O'Keefe 2c56ae4b9d docs(scanner): Beam batch 7 — all 10 scanner endpoints backfilled
Scanner surface frontmatter'd (scan/start/stop/cancel/status/active/
active-summary + watch start/stop/status) with provenance comments;
active-summary on a non-admin group.

Claude registry: 10 entries. Fixed handler drift Beam missed: scan_library
request has no "recursive"; scanner/start binds folder_paths (not
invented interval_seconds); scanner/stop binds no body; response is
{message, job_id, status} with 409 coalescing variant
{error, active_job_id}.

Gate fix in drift_test seed: registerScannerRoutes group seeds used bare
"jwt"/"admin" tokens invisible to authLabel; and silently passed before
only because the seeded set was unchecked. Seeds now 'jwtMiddleware'/
'AdminMiddleware' markers. Also: cleanup of a stale-merge-script residue
in collections/update_rule.md example (extra null keys). make test 9/9.
2026-10-24 17:20:46 -04:00
John O'Keefe 25ef0d12ba docs(kobo,koreader,collections): Beam WO5 batch 6 — 12 endpoints
Kobo emulation + KOReader calibre-compatible surface backfilled with
device-token auth; collections reorder + update_rule created.

Claude registry: 12 entries. kobo/koreader emulation endpoints all bind
arbitrary protocol POST bodies (markup/bookmark/gettests/sync-from-server,
koreader progress/bookmarks) — registered as AnyJSONObject, not bodyless as
first drafted. Initialization returns the library sync payload, not the init
response.

Contract correction in update_rule.md: the route is SetRuleEnabled ({enabled}
required -> {collection_id, is_smart}); Beam's draft invented rule-field
updates. Reordered response/status: {item_ids} -> {status: reordered}.

make test 9/9.
2026-10-24 16:25:00 -04:00
John O'Keefe b77eab7ffd docs(libraries): Beam WO5 batch 5 — 11 endpoints backfilled + pilot recovery
Beam backfilled 11 library docs (CRUD, folders, visibility, browse, stats,
media-items) and re-attached frontmatter to create_collection.md — which had
silently lost its pilot version in cc893f75: the negative-test
'git checkout --' restored the OLD file because the pilot was never staged,
and a frontmatter-less doc is invisible to the gate. Claude restored the full
corrected pilot content (priority higher-wins, time.String() note, 409).

Claude registry: 11 entries + one inverted catch — DELETE /:id/folders DOES
bind {folder_path} (handler reuses AddLibraryFolderRequest); registry first
draft said bodyless. GET /:id/visibility returns joined
GetUserVisibleLibrariesRow (type_name etc.), not bare Libraries.

Gate-driven merges fixed create/get library response shapes (sqlc row keys:
created_by_admin_id, library_type_id, updated_at; dropped invented type/
folders/is_visible). Fixed invented request fields (user_id in
set-visibility; library_type_id in update). make test 9/9.
2026-10-24 16:18:48 -04:00
John O'Keefe 6f13dc2b63 docs(media-items core): Beam WO5 batch 4 — 12 endpoints backfilled, registry pinned
Beam backfilled list/get/create/update/delete media-item, search, ratings
CRUD, progress GET/PUT. Beam correctly identified create/update/delete as
jwt+admin (admin group in the router — verified).

Claude registry: 12 entries. Notable pins:
- get_media_item: hand-built ~44-key map pinned verbatim from the handler
- create/update return the full database.MediaItems row (74 columns) —
  examples merged up to the real shape via gate-driven merge script
- search returns []SearchMediaItemsRow (sqlc row — Real type pinned)
- rating scale is 1-10 int (odd = half-star); GET returns {rating: null} when
  absent; update reuses the create handler (201)
- progress GET: 25 base keys + 4 CONDITIONAL position-authority keys
  (css_selector/anchor_href/char_offset/context_text — added by the handler
  for convertible formats with a valid CFI); PUT progress binds
  sync.SaveProgressRequest and returns database.ReadingProgress
- Beam's examples were missing entirely on 4 files (list/search/progress
  GET/PUT) — relabeled existing fences or inserted contract-shaped blocks

Gate-driven merge scripts (object + array variants) brought all examples to
the exact handler key sets. make test 9/9.
2026-10-24 16:09:38 -04:00
John O'Keefe ae9588f1c6 docs(auth,devices): Beam WO5 batch 3 — 15 endpoints backfilled, registry pinned
Beam backfilled auth core (login/register/refresh/logout/profile) and devices
core (list/update/delete/shelf CRUD/sidecar/register flow) with frontmatter,
provenance, and labeled examples. All claimed shapes verified against
handlers: AuthResponse {access_token, refresh_token omitempty, token_type,
expires_in, user}, update device {device_updated, device}, shelf wrappers,
DeviceAuthStatusResponse.

Claude registry verification: 15 entries pinned by type (LoginRequest,
RegisterRequest, AuthResponse, RefreshTokenRequest/Response, UserProfile,
DeviceListResponse, DeviceUpdateRequest, AddToShelfRequest, SidecarConfig,
DeviceRegistrationRequest/Response, DeviceAuthStatusRequest/Response).
Removed revoke_device.md — name-only duplicate of delete_device.md (same
DELETE /api/devices/:id route; was the last audit-orphan doc).

Gate catches this round (subtle Go serialization facts, now pinned in docs):
- uuid.UUID arrays never serialize as omitted: register/status response
  ALWAYS carries device_id (all-zeros while pending) despite omitempty
- update device request binds device_name, not name
- register response includes device_id alongside registration_id
- list devices response is a {devices, total} wrapper
2026-10-24 15:48:18 -04:00
John O'Keefe eacc33bfac docs(conflicts,queue): Beam WO5 batch — 13 endpoints backfilled, registry pinned
Beam added frontmatter + provenance + labeled examples to the 7 conflict and
6 queue docs, fixing real field drift on the way (resolve response keys,
bulk result shape, dismiss-all deleted count, queue stats counts).

Claude registry verification found TWO Beam catches of Claude's own draft:
- queue routes use :device_id / :item_id params (registry first draft said :id)
- resolve_conflict DOES take a body (ConflictResolutionRequest) — registry
  first draft said nil
Both fixed; gate then surfaced one real Beam defect: example-request used a
nonexistent "resolution" field in resolve + bulk-resolve (real contract:
winner=<source|manual> with required manual_data for manual; strategy=
most_recent|highest_progress|manual). Fixed in the docs.

Pinned by type: ConflictListResponse, ConflictDetailResponse,
ConflictResolveResponse, BulkResolveRequest/Response, QueueStatsResponse,
ConflictResolutionRequest. dismiss-all deleted is an int count.
2026-10-24 15:37:45 -04:00
John O'Keefe fa00d6d3cb docs(saved-filters,dashboard,system,series): Beam WO3 batch 5 — 15 endpoints + dedupe
Beam drafted 15 frontmatter'd docs (saved-filters CRUD, dashboard
sections/preferences/restore, system config/settings, series list + books);
Claude verified shapes against handlers and pinned the registry:
- saved-filters: SavedFilterResponse pinned by type (RFC3339 timestamps here,
  unlike most of the API); create/update share CreateSavedFilterRequest
  (resource_type oneof media-items|collections|devices)
- dashboard: preferences GET pin {hidden_collections, collection_order,
  items_per_section}; PUT returns full UserDashboardPreferences row; sections
  wrapper {sections}
- system: GET+PUT /api/system/config are admin-defined key/value maps — new
  AnyJSONObject sentinel (gate shape-checks without key comparison; request
  side too). UpdateSystemConfiguration returns {status, message} — verified,
  not guessed. GET /api/system/settings returns []SettingEntry.
- series: {series[{name, library_id, book_count, total_in_series,
  cover_paths, last_entry_at}], total, limit, offset}; books by
  ?name=&library_id= with family-lens 403

Dedupe: deleted dashboard.md, system/config.md, system/settings.md — each
covered the same endpoints as Beam's per-endpoint files (content merged by
the gate-verified shapes; old prose recoverable from git).

Beam clarifications confirmed: scanner routes are WO5 (docs exist);
progress/books-match are WO5.
2026-10-24 15:29:24 -04:00
John O'Keefe 7471ca99e8 docs(analytics,jobs): Work Order 3 batch 4 — device usage, popular books, job create/status
DeviceUsageResponse / PopularBooksResponse / services.JobResult pinned by
type; jobs create uses an inline anonymous request struct (RawMessage pin).
Two UNVERIFIED markers recorded (analytics date-range params, facet value
element shape).
2026-10-24 15:08:18 -04:00
John O'Keefe 4a6fb033cb docs(media-items): Work Order 3 batch 3b — management endpoints (8)
New frontmatter'd docs:
- download (ServeFile — raw bytes, family-lens enforced; corrects the old
  download doc's claim that no download route is registered: ServeFile IS
  routed, DownloadBook is the dead code)
- set_audio_speed (speed range 0.25-4.0, NaN/Inf rejected)
- rescan (abridged example over the full 70+-column row; write-after-success
  guarantee noted)
- purge-archived (destructive, admin), unarchive (204), merge (SHA-256
  precondition, per-user collisions keep active copy)
- facets (two modes: facet+values, field autocomplete counts)
- filter_count (family lens)

Registry: RawMessage pins for map/abridged responses; Schema{} for non-JSON
body routes (download = raw bytes; unarchive = 204). Audit now:
missing docs down to ~55 (from 112), 0 orphans.
2026-10-24 15:06:23 -04:00
John O'Keefe ed0ce5d077 docs(media-items): Work Order 3 batch 3a — reader-adjacent CRUD (11 endpoints)
New frontmatter'd docs with real shapes (sqlc models pinned directly):
- bookmarks: get/create/update/delete (sync-aware rows: dedup_key,
  last_modified_*, tombstones; delete is 204)
- review: upsert (empty string DELETES the review) / get (200 with
  {review:null} when absent) / delete
- get single note + highlight (full sqlc row shapes)
- annotation history: restore (POST, kind via query or body) and purge
  (DELETE, kind via required query; permanent)

Gate hardening forced by this batch:
- required-field walk now guards non-struct Request types (RawMessage pins)
- update_media_bookmark needed a full example block (no cross-reference
  shortcuts — the gate checks every doc standalone)
2026-10-24 15:03:36 -04:00
John O'Keefe 85cdaf7196 docs(libraries): Work Order 3 batch 2 — 10 missing endpoints + array-response gate support
New frontmatter'd docs with handler-compatible shapes:
- get_library_types.md (public; []LibraryTypes)
- list_libraries.md (admin; {data: ListLibrariesRow})
- get_library_folders.md (admin; []LibraryFolders)
- list/get stats/resolve/delete processing issues (admin; family-lens 403s
  documented)
- scan_library.md (202 + 409 coalescing with active_job_id)
- get/update scan-settings (ScanSettingsResponse)

Gate improvements forced by this batch:
- Array responses: registered slice -> doc example must be a JSON array; key
  set compared against the element type
- RawMessage pins may now be arrays
- update scan-settings uses a RawMessage pin: handler always sets Message but
  the struct's omitempty would reject correct docs via zero-value marshal

Registry: sqlc models pinned directly (LibraryTypes, LibraryFolders,
ListLibrariesRow — json tags verified in the generated code).
2026-10-24 14:57:45 -04:00
John O'Keefe c4b62af213 docs(devices): Work Order 3 batch 1 — 6 missing device endpoints + gate entries
New frontmatter'd docs with handler-compatible examples:
- devices/get_device.md (DeviceInfo shape)
- devices/regenerate_device_token.md (token + sync_urls)
- devices/get_device_mappings.md (wrapper + DeviceMappingSummary)
- devices/update_device_mapping.md (UpdateDeviceMappingRequest)
- devices/download_sidecar_config.md (sidecar config JSON file)
- devices/link_book.md (LinkBookRequest; one UNVERIFIED marker on required-tag)

Schema registry: 6 new entries; DeviceMappingSummary hoisted to package
scope. Gate passes (the UNVERIFIED marker is logged, not a failure).

Excludes /api/devices-page (htmx page route, per ground rules).
2026-10-24 14:46:22 -04:00
John O'Keefe 50282895f5 fix(docs): Work Order 2 — correct stale paths (11 docs + 3 Bruno)
Fix all wrong-path artifacts identified by the audit:
- Kobo sync endpoints (init/bookmark/markup/sync-from-server/analytics): add
  missing :token device segment (6 doc files incl. kobo-protocol.md)
- Highlights/notes update+delete: add missing /:id media-item prefix
- media-items/download_media_item.md: removed (static /uploads route, not an
  API endpoint; info about the file route is recoverable from OPDS docs)
- Bruno: /libraries/visible -> /visibility, /collections/books missing id,
  /audio/0 -> track_index
- operations/troubleshooting.md: same /libraries/visible -> /visibility fix
- media-items/get_audio_track.md + get_audio_chapters.md: /audio/0 examples fixed
- Deleted monolithic docs/developer/api-reference.md (duplicate of split docs)
- Fixed 4 stale entries in docs/developer/api/api-reference.md (split index)

Audit re-run: orphan docs 0, orphan Bruno 6 (all scenario files with literal IDs).
2026-10-24 14:44:19 -04:00
John O'Keefe cc893f758e feat(docs): drift gate — docs frontmatter validated against real routes
Every API doc that declares frontmatter (method/path/auth/area) is now
checked by a unit test against three sources of truth:

1. Route registration: internal/router/*.go is parsed the way echo resolves
   it (group prefixes concatenate; scanner.go's parameterized groups seeded).
   Doc paths that no longer exist fail. (This is the check that would have
   caught the /api/api/admin/hash-conflicts split.)
2. Auth: frontmatter auth label must match the middleware chain
   (none|jwt|jwt+admin|device-token|optional-jwt).
3. Shapes: internal/docs/schema.go registry — example-request decoded with
   unknown fields DISALLOWED (typo guard), required-by-validate fields must
   appear (staleness guard), example-response top-level key set compared
   both directions against the marshaled handler type (or a RawMessage pin
   for hand-built map responses).

Five collections docs are the pilots with real handler shapes; notable
corrections they pin: update returns 201 (not 200), list excludes smart
collections unless include_auto=true, rule priority is 1-10 higher-wins
(docs claimed 1=highest), created_at is Go time.String() not RFC3339,
hide_finished/show_up_next toggles documented. CollectionResponse hoisted
out of GetCollections/GetBookCollections as CollectionSummary /
BookCollectionSummary so the registry can pin real types.

The gate fails on missing registry entries by design — that is the
maintenance contract. Known pre-existing (not this commit): vet copy-lock
warnings in media.go.
2026-10-24 14:41:02 -04:00
John O'Keefe 994e0f86c4 chore(web): drop unreferenced highlight-dark.min.css
Docs highlighting is server-side chroma since the docs chrome rewrite; the
hljs stylesheet had no remaining referents.
2026-10-24 14:33:37 -04:00
John O'Keefe 11b179b91f fix(admin): hash-conflict routes served at sane path — resolve buttons were dead
The admin group already sits under the /api prefix, so registering
"/api/admin/hash-conflicts" produced /api/api/admin/hash-conflicts while the
hash-conflicts page's htmx buttons posted to /api/admin/hash-conflicts/:id/
resolve — a 404 that htmx swallows silently (no swap, no error), so duplicate-
content resolution has been broken since the feature shipped (03cb4c78).

Verified live: sane path 401 anon -> 200 with admin token; doubled path 404.
Caller audit found no other consumers (Android app, koplugin, web reader,
tests, Bruno: no references). Docs already describe the sane path and are now
correct without edits.
2026-10-24 14:33:37 -04:00
John O'Keefe 84cd6328f4 feat(docs): standalone docs site chrome + category/subcategory navigation
Docs pages no longer embed the app Header (whose hx-post login form silently
degraded to a native GET, leaking credentials into the URL — this page never
loaded htmx). New docs-owned topbar: search, theme picker, Open App; anonymous
visitors get a plain /login link. Optional-auth middleware (hand-rolled —
echojwt's ErrorHandler return ends the chain, producing empty 200s) populates
the user for signed-in visitors.

Navigation rebuilt from the docs directory tree: 6 sections, subcategory
groups, server-rendered expansion and active states. Search results are a
templ partial (/docs/search/partial); code highlighting is server-side chroma
(previously dead hljs CSS); prose is theme-aware via typography DEFAULT vars
(prose-invert painted unreadable text under light themes). /docs renders the
index directly; standalone 404 without the tailwind CDN.

Tests: navigation routing/expansion (internal/docs), render pins asserting no
login form/password field/app sidebar ever return on docs pages.
lint-rendering: exempt *.test.ts fixtures (jsdom DOM building); docs.ts
baseline 5 -> 1.
2026-10-24 14:31:36 -04:00
759 changed files with 22022 additions and 15990 deletions

No files matched your search

+1
View File
@@ -86,3 +86,4 @@ session-*.md
# Binary media fixtures: never commit (generate with ffmpeg at test time,
# see docs/contributing/development.md → Media fixtures)
internal/services/testdata/
test-results
+32
View File
@@ -41,6 +41,38 @@ with heavy interactivity. First-paint section markup is still templ
deliberate; do not clone it to new pages, and do not "fix" the dashboard
without restoring a templ-partial fade path first.
## CSP discipline (hash-allow-listed script-src) — ALWAYS apply
script-src carries a sha256 hash, not `'unsafe-inline'` (and never
`'unsafe-eval'` — the Alpine build is `@alpinejs/csp` for exactly this
reason; keep it in lockstep with alpinejs). The browser therefore
refuses, **silently at build/test time**, two authoring patterns:
- **No inline `<script>` content outside `theme_flash_guard.templ`.**
The guard is the app's only inline script; its bytes are pinned by
`middleware.ScriptHashThemeFlashGuard` + the drift test. Editing the
guard means recomputing the hash (the failing test prints it); adding
a *second* inline script means hashing it too and extending
`cspSanctionedInlineScripts` in `templates/csp_source_test.go`.
Everything else goes in a bundled `web/src` module.
- **No native `on*=` handler attributes** (`onclick=`, `onerror=`,
`onsubmit=`, `onchange=`, … — quote AND templ brace forms). Inline
handlers never go through the hash path; hash-mode CSP refuses them
unconditionally. Use Alpine `@click`/`@change`/… bound to component
methods (see the 2026-10 CSP-build conversion commits for the
pattern), or delegated listeners for htmx-swapped content.
- **No htmx eval-dependent bindings** (`hx-on*`, `js:`-prefixed
`hx-vals`) — `'unsafe-eval'` is not granted.
`templates/csp_source_test.go` is the tripwire for all three (it
scans the `.templ` sources; it runs in seconds on host). `style-src
'unsafe-inline'` remains by design (htmx-injected styles, Alpine
`:style`); `'self'` still trusts the origin not to serve
attacker-supplied scripts — don't let upload paths become
script-executable. Remember: `templ` does not interpolate `{ expr }`
inside quoted attribute values (gotcha #1 below), so dynamic
`@click={ "fn('" + id + "')" }` attribute values must BE expressions.
## Library visibility (family-lens rule) — ALWAYS apply
Per-user library visibility (`library_visibility` table, default-ALLOW:
+4 -2
View File
@@ -54,11 +54,13 @@ test: lint-rendering
# UI markup, JS orchestrates. Fails when a web/src file builds more HTML
# template literals than its recorded baseline (web/src/rendering-allowlist.txt),
# or when a .templ file gains an inline <script> block (theme_flash_guard.templ
# is the only sanctioned one). Shrink baselines as fragments move to templ
# is the only sanctioned one). *.test.ts files are excluded: jsdom fixtures
# build DOM in tests, they don't render shipped UI. Shrink baselines as
# fragments move to templ
# partials; grow one only with a written reason.
lint-rendering:
@violations=""; \
for f in $$(grep -rlE 'innerHTML[[:space:]]*=[[:space:]]*' web/src --include='*.ts' | grep -v 'web/src/reader/'); do \
for f in $$(grep -rlE 'innerHTML[[:space:]]*=[[:space:]]*' web/src --include='*.ts' | grep -v 'web/src/reader/' | grep -v '\.test\.ts$$'); do \
count=$$(grep -E 'innerHTML[[:space:]]*=[[:space:]]*' $$f | grep -cvE 'innerHTML[[:space:]]*=[[:space:]]*""[[:space:]]*$$'); \
allowed=$$(grep -F "$$f:" web/src/rendering-allowlist.txt | cut -d: -f2); \
allowed=$${allowed:-0}; \
-30
View File
@@ -1,30 +0,0 @@
info:
name: Get Admin Library
type: http
seq: 5
http:
method: GET
url: '{{base_url}}/admin/library'
auth: inherit
body:
type: none
docs: |-
## Get Admin Library Page
Retrieves the admin library page for administrative access.
**Method:** GET
**Endpoint:** /admin/library
**Headers:**
- `Authorization` (string): Bearer token
**Response:**
- HTML content for the admin library page
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 403: Forbidden
+31
View File
@@ -0,0 +1,31 @@
info:
name: Hash Conflicts (API)
type: http
seq: 1
http:
method: GET
url: '{{base_url}}/api/admin/hash-conflicts'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Hash Conflicts (API)
`GET /api/admin/hash-conflicts` — auth: jwt+admin.
Canonical reference: docs/developer/api/admin/hash_conflicts_api.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: List Users
type: http
seq: 2
http:
method: GET
url: '{{base_url}}/api/auth/users'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# List Users
`GET /api/auth/users` — auth: jwt+admin.
Canonical reference: docs/developer/api/admin/list_users.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+37
View File
@@ -0,0 +1,37 @@
info:
name: Update User Max Devices
type: http
seq: 3
http:
method: PUT
url: '{{base_url}}/api/auth/users/{{media_item_id}}/max-devices'
auth: inherit
body:
type: json
data: |-
{
"max_devices": 15
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Update User Max Devices
`PUT /api/auth/users/:id/max-devices` — auth: jwt+admin.
Canonical reference: docs/developer/api/admin/update_user_max_devices.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
-47
View File
@@ -1,47 +0,0 @@
info:
name: Get Device Usage
type: http
seq: 2
http:
method: GET
url: '{{base_url}}/api/analytics/device-usage'
auth: inherit
body:
type: none
runtime:
scripts:
- type: tests
code: "test(\"status must be 200\", function() {\n expect(res.status).to.eql(200);"
docs: |-
Get usage statistics for all devices.
**Endpoint**: GET /api/analytics/device-usage
**Auth**: Required (Bearer token)
## Response Fields
| Field | Type | Description |
|-------|------|-------------|
| devices | array | List of device usage statistics |
| devices[].id | string | Device ID |
| devices[].device_name | string | Device name |
| devices[].device_type | string | Device type (kobo, kindle, koreader) |
| devices[].sync_count | int | Number of sync operations |
| devices[].last_sync | string | Last sync timestamp |
| devices[].total_reading_minutes | int | Total reading time on device |
| devices[].books_read | int | Number of books completed on device |
## Example Response
```json
{
"devices": [
{
"id": "789e4567-e89b-12d3-a456-426614174001",
"device_name": "My Kobo Clara",
"device_type": "kobo",
"sync_count": 45,
"last_sync": "2026-02-08T17:25:00Z",
"total_reading_minutes": 1250,
"books_read": 3
-57
View File
@@ -1,57 +0,0 @@
info:
name: Get Popular Books
type: http
seq: 1
http:
method: GET
url: '{{base_url}}/api/analytics/popular-books?limit=10'
auth: inherit
body:
type: none
runtime:
scripts:
- type: tests
code: "test(\"status must be 200\", function() {\n expect(res.status).to.eql(200);"
docs: |-
Get popular books sorted by read count.
**Endpoint**: GET /api/analytics/popular-books
**Auth**: Required (Bearer token)
## Query Parameters
| Parameter | Type | Required | Description |
|-----------|------|-----------|-------------|
| limit | int | No | Maximum number of books to return (default: 10) |
## Response Fields
| Field | Type | Description |
|-------|------|-------------|
| books | array | List of popular books |
| books[].media_item_id | string | Book ID |
| books[].title | string | Book title |
| books[].author | string | Book author |
| books[].read_count | int | Number of times read |
| books[].avg_completion | float | Average completion rate (0-1) |
| books[].cover_url | string | Cover image URL |
## Example Request
```
GET /api/analytics/popular-books?limit=10
```
## Example Response
```json
{
"books": [
{
"media_item_id": "323e4567-e89b-12d3-a456-426614174002",
"title": "The Great Gatsby",
"author": "F. Scott Fitzgerald",
"read_count": 5,
"avg_completion": 0.85,
"cover_url": "/api/books/323e4567-e89b-12d3-a456-426614174002/cover"
@@ -1,58 +0,0 @@
info:
name: Get Reading Stats Date Range
type: http
seq: 3
http:
method: GET
url: '{{base_url}}/api/analytics/reading-stats?start_date=2024-01-01&end_date=2024-01-31'
auth: inherit
body:
type: none
runtime:
scripts:
- type: tests
code: "test(\"status must be 200\", function() {\n expect(res.status).to.eql(200);"
docs: |-
Get reading statistics for a specific date range.
**Endpoint**: GET /api/analytics/reading-stats
**Auth**: Required (Bearer token)
## Query Parameters
| Parameter | Type | Required | Description |
|-----------|------|-----------|-------------|
| start_date | string | No | Start date (ISO 8601 format, default: 30 days ago) |
| end_date | string | No | End date (ISO 8601 format, default: today) |
## Response Fields
| Field | Type | Description |
|-------|------|-------------|
| total_books_read | int | Total books completed in range |
| total_pages_read | int | Total pages read in range |
| total_reading_time_minutes | int | Total reading time in minutes |
| completion_rate | float | Percentage of books completed (0-1) |
| daily_reading_minutes | array | Daily reading time per day |
| daily_reading_minutes[].date | string | Date (ISO 8601) |
| daily_reading_minutes[].minutes | int | Minutes read on that date |
## Example Request
```
GET /api/analytics/reading-stats?start_date=2024-01-01&end_date=2024-01-31
```
## Example Response
```json
{
"total_books_read": 2,
"total_pages_read": 450,
"total_reading_time_minutes": 720,
"completion_rate": 0.85,
"daily_reading_minutes": [
{
"date": "2024-01-01",
"minutes": 30
-58
View File
@@ -1,58 +0,0 @@
info:
name: Get Reading Stats
type: http
seq: 4
http:
method: GET
url: '{{base_url}}/api/analytics/reading-stats'
auth: inherit
body:
type: none
runtime:
scripts:
- type: tests
code: "test(\"status must be 200\", function() {\n expect(res.status).to.eql(200);"
docs: |-
Get overall reading statistics for the authenticated user.
**Endpoint**: GET /api/analytics/reading-stats
**Auth**: Required (Bearer token)
## Query Parameters
| Parameter | Type | Required | Description |
|-----------|------|-----------|-------------|
| start_date | string | No | Start date (ISO 8601 format) |
| end_date | string | No | End date (ISO 8601 format) |
## Response Fields
| Field | Type | Description |
|-------|------|-------------|
| total_books_read | int | Total books completed |
| total_pages_read | int | Total pages read |
| total_reading_time_minutes | int | Total reading time in minutes |
| completion_rate | float | Average book completion rate (0-1) |
| daily_reading_minutes | array | Daily reading time breakdown |
| daily_reading_minutes[].date | string | Date (ISO 8601) |
| daily_reading_minutes[].minutes | int | Minutes read on that date |
## Example Request
```
GET /api/analytics/reading-stats
```
## Example Response
```json
{
"total_books_read": 12,
"total_pages_read": 3450,
"total_reading_time_minutes": 5400,
"completion_rate": 0.78,
"daily_reading_minutes": [
{
"date": "2026-01-15",
"minutes": 45
+31
View File
@@ -0,0 +1,31 @@
info:
name: Get Reading Statistics
type: http
seq: 1
http:
method: GET
url: '{{base_url}}/api/analytics/reading-stats'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Reading Statistics
`GET /api/analytics/reading-stats` — auth: jwt.
Canonical reference: docs/developer/api/analytics/get_analytics.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: Get Device Usage
type: http
seq: 2
http:
method: GET
url: '{{base_url}}/api/analytics/device-usage'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Device Usage
`GET /api/analytics/device-usage` — auth: jwt.
Canonical reference: docs/developer/api/analytics/get_device_usage.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: Get Popular Books
type: http
seq: 3
http:
method: GET
url: '{{base_url}}/api/analytics/popular-books'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Popular Books
`GET /api/analytics/popular-books` — auth: jwt.
Canonical reference: docs/developer/api/analytics/get_popular_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+59
View File
@@ -0,0 +1,59 @@
info:
name: Login User
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/auth/login'
auth: inherit
body:
type: json
data: |-
{
"login": "user@example.com",
"password": "SecureP@ss123!"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: after-response
code: |-
function onResponse(res) {
try {
const responseBody = res.getBody();
const token = responseBody.access_token;
const refreshToken = responseBody.refresh_token;
if (token) {
bru.setEnvVar("token", token, { persist: true });
console.log("Access token saved");
}
if (refreshToken) {
bru.setEnvVar("refresh_token", refreshToken, { persist: true });
console.log("Refresh token saved");
}
} catch (error) {
console.error("Token capture failed:", error.message);
}
}
onResponse();
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Login User
`POST /api/auth/login` — auth: none.
Canonical reference: docs/developer/api/authentication/login.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: Logout User
type: http
seq: 2
http:
method: POST
url: '{{base_url}}/api/auth/logout'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Logout User
`POST /api/auth/logout` — auth: none.
Canonical reference: docs/developer/api/authentication/logout.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: Password Rules
type: http
seq: 3
http:
method: GET
url: '{{base_url}}/api/auth/password-rules'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Password Rules
`GET /api/auth/password-rules` — auth: none.
Canonical reference: docs/developer/api/authentication/password_rules.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+58
View File
@@ -0,0 +1,58 @@
info:
name: Refresh Token
type: http
seq: 4
http:
method: POST
url: '{{base_url}}/api/auth/refresh'
auth: inherit
body:
type: json
data: |-
{
"refresh_token": "d4f5g6h7..."
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: after-response
code: |-
function onResponse(res) {
try {
const responseBody = res.getBody();
const token = responseBody.access_token;
const refreshToken = responseBody.refresh_token;
if (token) {
bru.setEnvVar("token", token, { persist: true });
console.log("Access token saved");
}
if (refreshToken) {
bru.setEnvVar("refresh_token", refreshToken, { persist: true });
console.log("Refresh token saved");
}
} catch (error) {
console.error("Token capture failed:", error.message);
}
}
onResponse();
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Refresh Token
`POST /api/auth/refresh` — auth: none.
Canonical reference: docs/developer/api/authentication/refresh_token.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+62
View File
@@ -0,0 +1,62 @@
info:
name: Register User
type: http
seq: 5
http:
method: POST
url: '{{base_url}}/api/auth/register'
auth: inherit
body:
type: json
data: |-
{
"email": "user@example.com",
"username": "john",
"password": "SecureP@ss123!",
"first_name": "John",
"last_name": "Doe"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: after-response
code: |-
function onResponse(res) {
try {
const responseBody = res.getBody();
const token = responseBody.access_token;
const refreshToken = responseBody.refresh_token;
if (token) {
bru.setEnvVar("token", token, { persist: true });
console.log("Access token saved");
}
if (refreshToken) {
bru.setEnvVar("refresh_token", refreshToken, { persist: true });
console.log("Refresh token saved");
}
} catch (error) {
console.error("Token capture failed:", error.message);
}
}
onResponse();
- type: tests
code: |-
test("status is 201", function() {
expect(res.status).to.eql(201);
});
docs:
content: |-
# Register User
`POST /api/auth/register` — auth: none.
Canonical reference: docs/developer/api/authentication/register.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,40 @@
info:
name: Register - Admin Role Unauthenticated (403)
type: http
seq: 2
http:
method: POST
url: '{{base_url}}/api/auth/register'
auth: inherit
body:
type: json
data: |-
{
"email": "smoke-admin-attempts@tests.bookhoard.internal",
"username": "smokeadminattempts",
"password": "Sm0ke@Pass123!",
"role": "admin"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 403", function() {
expect(res.status).to.eql(403);
});
docs:
content: |-
# Register - Admin Role Unauthenticated (403)
With admins already present, an unauthenticated register attempt
carrying role=admin must be rejected with 403 and create nothing.
Repeats safely.
type: text/markdown
@@ -0,0 +1,39 @@
info:
name: Register - Duplicate Email (409)
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/auth/register'
auth: inherit
body:
type: json
data: |-
{
"email": "testuser@tests.bookhoard.internal",
"username": "testuser",
"password": "Wh4t3v3r@Pass!"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 409", function() {
expect(res.status).to.eql(409);
});
docs:
content: |-
# Register - Duplicate Email (409)
Registering with an email that already exists must fail with 409 and
create nothing. Negative scenario — hand-authored (the generated set
covers only happy paths). Repeats safely.
type: text/markdown
@@ -0,0 +1,38 @@
info:
name: Register - Weak Password (400)
type: http
seq: 3
http:
method: POST
url: '{{base_url}}/api/auth/register'
auth: inherit
body:
type: json
data: |-
{
"email": "smoke-weak-attempts@tests.bookhoard.internal",
"username": "smokeweakattempts",
"password": "weak"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 400", function() {
expect(res.status).to.eql(400);
});
docs:
content: |-
# Register - Weak Password (400)
Passwords below the configured complexity rules (see
GET /api/auth/password-rules) must be rejected with 400. Repeats safely.
type: text/markdown
+38
View File
@@ -0,0 +1,38 @@
info:
name: Auto Link Books
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/sync/auto-link-books'
auth: inherit
body:
type: json
data: |-
{
"confidence_threshold": 0.8,
"limit": 100
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Auto Link Books
`POST /api/sync/auto-link-books` — auth: jwt.
Canonical reference: docs/developer/api/book-matching/auto_link_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+48
View File
@@ -0,0 +1,48 @@
info:
name: Bulk Link Books
type: http
seq: 2
http:
method: POST
url: '{{base_url}}/api/sync/bulk-link-books'
auth: inherit
body:
type: json
data: |-
{
"links": [
{
"unlinked_book_id": "770e8400-e29b-41d4-a716-446655440002",
"media_item_id": "660e8400-e29b-41d4-a716-446655440001",
"confidence_score": 0.95
},
{
"unlinked_book_id": "880e8400-e29b-41d4-a716-446655440003",
"media_item_id": "990e8400-e29b-41d4-a716-446655440004",
"confidence_score": 0.87
}
]
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Bulk Link Books
`POST /api/sync/bulk-link-books` — auth: jwt.
Canonical reference: docs/developer/api/book-matching/bulk_link_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,39 @@
info:
name: Create Device File Alias
type: http
seq: 3
http:
method: POST
url: '{{base_url}}/api/devices/{{device_id}}/file-aliases'
auth: inherit
body:
type: json
data: |-
{
"media_item_id": "550e8400-e29b-41d4-a716-446655440001",
"file_name": "book.epub",
"file_hash": "abc123..."
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 201", function() {
expect(res.status).to.eql(201);
});
docs:
content: |-
# Create Device File Alias
`POST /api/devices/:id/file-aliases` — auth: jwt.
Canonical reference: docs/developer/api/book-matching/create_device_file_alias.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,31 @@
info:
name: Delete Device File Alias
type: http
seq: 4
http:
method: DELETE
url: '{{base_url}}/api/devices/{{device_id}}/file-aliases/{{alias_id}}'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 204", function() {
expect(res.status).to.eql(204);
});
docs:
content: |-
# Delete Device File Alias
`DELETE /api/devices/:id/file-aliases/:aliasId` — auth: jwt.
Canonical reference: docs/developer/api/book-matching/delete_device_file_alias.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: Get Book Matches
type: http
seq: 5
http:
method: GET
url: '{{base_url}}/api/books/match'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Book Matches
`GET /api/books/match` — auth: jwt.
Canonical reference: docs/developer/api/book-matching/get_book_matches.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,31 @@
info:
name: Get Device File Aliases
type: http
seq: 6
http:
method: GET
url: '{{base_url}}/api/devices/{{device_id}}/file-aliases'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Device File Aliases
`GET /api/devices/:id/file-aliases` — auth: jwt.
Canonical reference: docs/developer/api/book-matching/get_device_file_aliases.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,31 @@
info:
name: Get Unlinked Book Suggestions
type: http
seq: 7
http:
method: GET
url: '{{base_url}}/api/sync/unlinked-books/{{unlinked_book_id}}/suggestions'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Unlinked Book Suggestions
`GET /api/sync/unlinked-books/:id/suggestions` — auth: jwt.
Canonical reference: docs/developer/api/book-matching/get_unlinked_book_suggestions.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,31 @@
info:
name: Get Unlinked Books
type: http
seq: 8
http:
method: GET
url: '{{base_url}}/api/devices/{{device_id}}/sync/unlinked-books'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Unlinked Books
`GET /api/devices/:deviceId/sync/unlinked-books` — auth: jwt.
Canonical reference: docs/developer/api/book-matching/get_unlinked_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+43
View File
@@ -0,0 +1,43 @@
info:
name: Link Book
type: http
seq: 9
http:
method: POST
url: '{{base_url}}/api/devices/{{device_id}}/sync/link-book'
auth: inherit
body:
type: json
data: |-
{
"device_file": {
"file_path": "/books/hobbit.epub",
"sha256": "abc123...",
"title": "The Hobbit"
},
"media_item_id": "550e8400-e29b-41d4-a716-446655440000",
"confidence_score": 1.0
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Link Book
`POST /api/devices/:deviceId/sync/link-book` — auth: jwt.
Canonical reference: docs/developer/api/book-matching/link_book.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+41
View File
@@ -0,0 +1,41 @@
info:
name: Search Books for Matching
type: http
seq: 10
http:
method: POST
url: '{{base_url}}/api/sync/books/query'
auth: inherit
body:
type: json
data: |-
{
"identifiers": ["isbn:978-0345391802", "uuid:abc-123"],
"sha256": "a1b2c3d4e5f6abc123...",
"title": "The Hobbit",
"author": "J.R.R. Tolkien",
"file_size": 2456789
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Search Books for Matching
`POST /api/sync/books/query` — auth: jwt.
Canonical reference: docs/developer/api/book-matching/search_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,38 @@
info:
name: Update Device File Alias
type: http
seq: 11
http:
method: PUT
url: '{{base_url}}/api/devices/{{device_id}}/file-aliases/{{alias_id}}'
auth: inherit
body:
type: json
data: |-
{
"file_name": "updated-book.epub",
"file_hash": "newhash123..."
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Update Device File Alias
`PUT /api/devices/:id/file-aliases/:aliasId` — auth: jwt.
Canonical reference: docs/developer/api/book-matching/update_device_file_alias.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
-15
View File
@@ -1,15 +0,0 @@
info:
name: Bulk Delete Media Items
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/media-items/bulk-delete'
auth: inherit
body:
type: json
data: "{\n \"media_item_ids\": [\n \"{{bookId1"
headers:
- key: Content-Type
value: application/json
docs: "## Bulk Delete Media Items\n\n Deletes multiple media items in a single request.\n\n **Method:** POST\n\n **Endpoint:** /api/media-items/bulk-delete\n\n **Authentication:** Required (Bearer token)\n\n **Request Body:**\n - `media_item_ids` (array of strings): Array of media item UUIDs to delete\n\n **Response:**\n - `results` (array): Results for each deletion attempt\n - `total` (number): Total number of media items processed\n - `deleted` (number): Number of successfully deleted media items\n - `failed` (number): Number of failed deletions\n\n **Status Codes:**\n - 200: Success (with partial results if some failed)\n - 400: Invalid request data\n - 401: Unauthorized\n - 403: Forbidden\n - 500: Internal server error\n\n **Example:**\n ```json\n {\n \"media_item_ids\": [\n \"uuid-1\",\n \"uuid-2\",\n \"uuid-3\"\n ]"
-16
View File
@@ -1,16 +0,0 @@
info:
name: Bulk Update Media Items
type: http
seq: 2
http:
method: POST
url: '{{base_url}}/api/media-items/bulk-update'
auth: inherit
body:
type: json
data: "{\n \"media_item_updates\": [\n {\n \"media_item_id\": \"{{bookId1"
headers:
- key: Content-Type
value: application/json
docs: "## Bulk Update Media Items\n\n Updates multiple media items in a single request with different fields for each item.\n\n **Method:** POST\n\n **Endpoint:** /api/media-items/bulk-update\n\n **Authentication:** Required (Bearer token)\n\n **Request Body:**\n - `media_item_updates` (array): Array of update objects\n - `media_item_id` (string): Media item UUID to update\n - `updates` (object): Fields to update (can include title, author, genre, tags, contributors, etc.)\n\n **Response:**\n - `results` (array): Results for each update attempt\n - `total` (number): Total number of media items processed\n - `updated` (number): Number of successfully updated media items\n - `failed` (number): Number of failed updates\n\n **Status Codes:**\n - 200: Success (with partial results if some failed)\n - 400: Invalid request data\n - 401: Unauthorized\n - 403: Forbidden\n - 500: Internal server error\n\n **Example:**\n ```json\n {\n \"media_item_updates\": [\n {\n\
\ \"media_item_id\": \"uuid-1\",\n \"updates\": {\n \"title\": \"New Title\",\n \"genre\": \"Fiction\",\n \"tags\": [\"fiction\", \"adventure\"]"
@@ -1,12 +0,0 @@
info:
name: Add Books to Collection
type: http
seq: 6
http:
method: POST
url: '{{base_url}}/api/collections/{{collection_id}}/books'
auth: inherit
body:
type: json
data: "{\n \"book_ids\": [\n \"{{book_id_1}}"
docs: "## Add Books to Collection\n\n Adds multiple books to an existing collection.\n \n **Method:** POST\n \n **Endpoint:** /api/collections/{collection_id}/books\n \n **Authentication:** Required (Bearer token)\n \n **Path Parameters:**\n - `collection_id` (string): Collection ID\n \n **Request Body:**\n - `book_ids` (array of strings, required): List of book IDs to add\n \n **Response:**\n - `message` (string): Success message\n - `added_count` (integer): Number of books added\n \n **Status Codes:**\n - 200: Success\n - 400: Invalid request\n - 401: Unauthorized\n - 404: Collection not found"
-13
View File
@@ -1,13 +0,0 @@
info:
name: Create Collection
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/collections'
auth: inherit
body:
type: json
data: "{\n \"name\": \"Science Fiction\",\n \"description\": \"My favorite sci-fi books\",\n \"color\": \"#ff0000\",\n \"icon\": \"\U0001F680\",\n \"auto_assign_rules\": [\n {\n \"id\": \"rule-1\",\n \"field\": \"genre\",\n \"operator\": \"equals\",\n \"value\": \"Science Fiction\",\n \"priority\": 8"
docs: "## Create Collection\n\n Creates a new collection for organizing books.\n \n **Method:** POST\n \n **Endpoint:** /api/collections\n \n **Authentication:** Required (Bearer token)\n \n **Request Body:**\n - `name` (string, required): Collection name (max 100 chars)\n - `description` (string, optional): Collection description (max 500 chars)\n - `color` (string, optional): Hex color code (default #3498db)\n - `icon` (string, optional): Emoji icon (default \U0001F4DA)\n - `auto_assign_rules` (array, optional): Auto-assignment rules\n - `id` (string): Rule ID\n - `field` (string): Field to match (genre, author, series, etc.)\n - `operator` (string): Comparison operator (equals, contains, startsWith)\n - `value` (string): Value to match\n - `priority` (integer): Rule priority (1-10)\n \n **Response:**\n - Collection object with generated ID\n \n **Status Codes:**\n - 201: Created\n - 400: Invalid request\n - 401: Unauthorized\n - 409: Collection name\
\ already exists"
@@ -1,12 +0,0 @@
info:
name: Create Device Mapping
type: http
seq: 9
http:
method: POST
url: '{{base_url}}/api/devices/{{device_id}}/collections'
auth: inherit
body:
type: json
data: "{\n \"collection_id\": \"{{collection_id}}"
docs: "## Create Device Mapping\n\n Maps a collection to a device shelf for synchronization.\n \n **Method:** POST\n \n **Endpoint:** /api/devices/{device_id}/collections\n \n **Authentication:** Required (Bearer token)\n \n **Path Parameters:**\n - `device_id` (string): Device ID\n \n **Request Body:**\n - `collection_id` (string, required): Collection ID to map\n - `device_shelf_name` (string, optional): Custom shelf name on device\n - `sync_direction` (string, optional): sync direction (book_to_device, device_to_book, bidirectional)\n \n **Response:**\n - Mapping object with generated ID\n \n **Status Codes:**\n - 201: Created\n - 400: Invalid request\n - 401: Unauthorized\n - 404: Device or collection not found"
-30
View File
@@ -1,30 +0,0 @@
info:
name: Delete Collection
type: http
seq: 5
http:
method: DELETE
url: '{{base_url}}/api/collections/{{collection_id}}'
auth: inherit
docs: |-
## Delete Collection
Permanently deletes a collection and all its mappings.
**Method:** DELETE
**Endpoint:** /api/collections/{collection_id}
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `collection_id` (string): Collection ID
**Response:**
- `message` (string): Success message
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Collection not found
@@ -1,31 +0,0 @@
info:
name: Delete Device Mapping
type: http
seq: 11
http:
method: DELETE
url: '{{base_url}}/api/devices/{{device_id}}/collections/{{mapping_id}}'
auth: inherit
docs: |-
## Delete Device Mapping
Removes a collection-to-device mapping.
**Method:** DELETE
**Endpoint:** /api/devices/{device_id}/collections/{mapping_id}
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `device_id` (string): Device ID
- `mapping_id` (string): Mapping ID
**Response:**
- `message` (string): Success message
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Device or mapping not found
@@ -1,44 +0,0 @@
info:
name: Get Book Collections
type: http
seq: 12
http:
method: GET
url: "{{base_url}}/api/collections/books"
body:
type: json
data: |-
{
"collection_id": "{{collection_id}}"
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs: |-
## Get Book Collections
Retrieves all collections that a specific book belongs to.
**Method:** GET
**Endpoint:** /api/collections/books
**Authentication:** Required (Bearer token)
**Query Parameters:**
- `book_id` (string, required): Book ID
**Response:**
- Array of collection objects containing the book
**Status Codes:**
- 200: Success
- 400: Invalid request
- 401: Unauthorized
- 404: Book not found
-30
View File
@@ -1,30 +0,0 @@
info:
name: Get Collection
type: http
seq: 3
http:
method: GET
url: '{{base_url}}/api/collections/{{collection_id}}'
auth: inherit
docs: |-
## Get Collection
Retrieves detailed information about a specific collection.
**Method:** GET
**Endpoint:** /api/collections/{collection_id}
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `collection_id` (string): Collection ID
**Response:**
- Collection object with all fields including books, rules, and mappings
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Collection not found
-31
View File
@@ -1,31 +0,0 @@
info:
name: Get Collections
type: http
seq: 2
http:
method: GET
url: '{{base_url}}/api/collections?include_auto=true&sort_by=name'
auth: inherit
docs: |-
## Get Collections
Retrieves all collections for the authenticated user.
**Method:** GET
**Endpoint:** /api/collections
**Authentication:** Required (Bearer token)
**Query Parameters:**
- `include_auto` (boolean, optional): Include auto-created collections (default false)
- `sort_by` (string, optional): Sort field (name, created_at, book_count)
- `order` (string, optional): Sort order (asc, desc)
**Response:**
- Array of collection objects
**Status Codes:**
- 200: Success
- 401: Unauthorized
-30
View File
@@ -1,30 +0,0 @@
info:
name: Get Device Mappings
type: http
seq: 8
http:
method: GET
url: '{{base_url}}/api/devices/{{device_id}}/collections'
auth: inherit
docs: |-
## Get Device Mappings
Retrieves all collection mappings for a specific device.
**Method:** GET
**Endpoint:** /api/devices/{device_id}/collections
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `device_id` (string): Device ID
**Response:**
- Array of device mapping objects with collection details
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Device not found
@@ -1,31 +0,0 @@
info:
name: Remove Book from Collection
type: http
seq: 7
http:
method: DELETE
url: '{{base_url}}/api/collections/{{collection_id}}/books/{{book_id}}'
auth: inherit
docs: |-
## Remove Book from Collection
Removes a single book from a collection.
**Method:** DELETE
**Endpoint:** /api/collections/{collection_id}/books/{book_id}
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `collection_id` (string): Collection ID
- `book_id` (string): Book ID
**Response:**
- `message` (string): Success message
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Collection or book not found
-12
View File
@@ -1,12 +0,0 @@
info:
name: Update Collection
type: http
seq: 4
http:
method: PUT
url: '{{base_url}}/api/collections/{{collection_id}}'
auth: inherit
body:
type: json
data: "{\n \"name\": \"Sci-Fi Favorites\",\n \"description\": \"Updated description\",\n \"color\": \"#00ff00\",\n \"icon\": \"⭐\",\n \"auto_assign_rules\": [\n {\n \"id\": \"rule-2\",\n \"field\": \"series\",\n \"operator\": \"equals\",\n \"value\": \"Foundation\",\n \"priority\": 9"
docs: "## Update Collection\n\n Updates an existing collection's properties.\n \n **Method:** PUT\n \n **Endpoint:** /api/collections/{collection_id}\n \n **Authentication:** Required (Bearer token)\n \n **Path Parameters:**\n - `collection_id` (string): Collection ID\n \n **Request Body:**\n - `name` (string, optional): New collection name\n - `description` (string, optional): New description\n - `color` (string, optional): New hex color code\n - `icon` (string, optional): New emoji icon\n - `auto_assign_rules` (array, optional): Updated auto-assignment rules\n \n **Response:**\n - Updated collection object\n \n **Status Codes:**\n - 200: Success\n - 400: Invalid request\n - 401: Unauthorized\n - 404: Collection not found\n - 409: Collection name already exists"
@@ -1,12 +0,0 @@
info:
name: Update Device Mapping
type: http
seq: 10
http:
method: PUT
url: '{{base_url}}/api/devices/{{device_id}}/collections/{{mapping_id}}'
auth: inherit
body:
type: json
data: "{\n \"device_shelf_name\": \"Science Fiction\",\n \"sync_direction\": \"book_to_device\""
docs: "## Update Device Mapping\n\n Updates settings for a collection-to-device mapping.\n \n **Method:** PUT\n \n **Endpoint:** /api/devices/{device_id}/collections/{mapping_id}\n \n **Authentication:** Required (Bearer token)\n \n **Path Parameters:**\n - `device_id` (string): Device ID\n - `mapping_id` (string): Mapping ID\n \n **Request Body:**\n - `device_shelf_name` (string, optional): Custom shelf name on device\n - `sync_direction` (string, optional): Sync direction (book_to_device, device_to_book, bidirectional)\n \n **Response:**\n - Updated mapping object\n \n **Status Codes:**\n - 200: Success\n - 400: Invalid request\n - 401: Unauthorized\n - 404: Device or mapping not found"
@@ -0,0 +1,41 @@
info:
name: Add Auto-Assign Rule
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/collections/{{collection_id}}/rules'
auth: inherit
body:
type: json
data: |-
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction",
"priority": 1,
"enabled": true
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 201", function() {
expect(res.status).to.eql(201);
});
docs:
content: |-
# Add Auto-Assign Rule
`POST /api/collections/:id/rules` — auth: jwt.
Canonical reference: docs/developer/api/collections/add_auto_assign_rule.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+36
View File
@@ -0,0 +1,36 @@
info:
name: Bulk Add Books
type: http
seq: 2
http:
method: POST
url: '{{base_url}}/api/collections/bulk-add-books'
auth: inherit
body:
type: json
data: |-
{
"operations": [
{
"collection_id": "550e8400-e29b-41d4-a716-446655440000",
"book_ids": [
"660e8400-e29b-41d4-a716-446655440001"
]
}
]
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs:
content: |-
# Bulk Add Books
`POST /api/collections/bulk-add-books` — auth: jwt.
Canonical reference: docs/developer/api/collections/bulk_add_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+41
View File
@@ -0,0 +1,41 @@
info:
name: Bulk Assign Books
type: http
seq: 3
http:
method: POST
url: '{{base_url}}/api/collections/{{collection_id}}/books'
auth: inherit
body:
type: json
data: |-
{
"book_ids": [
"660e8400-e29b-41d4-a716-446655440000",
"770e8400-e29b-41d4-a716-446655440000",
"880e8400-e29b-41d4-a716-446655440000"
]
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 204", function() {
expect(res.status).to.eql(204);
});
docs:
content: |-
# Bulk Assign Books
`POST /api/collections/:id/books` — auth: jwt.
Canonical reference: docs/developer/api/collections/bulk_assign.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+32
View File
@@ -0,0 +1,32 @@
info:
name: Bulk Remove Books
type: http
seq: 4
http:
method: POST
url: '{{base_url}}/api/collections/{{collection_id}}/books/bulk-remove'
auth: inherit
body:
type: json
data: |-
{
"book_ids": [
"550e8400-e29b-41d4-a716-446655440000",
"660e8400-e29b-41d4-a716-446655440001"
]
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs:
content: |-
# Bulk Remove Books
`POST /api/collections/:id/books/bulk-remove` — auth: jwt.
Canonical reference: docs/developer/api/collections/bulk_remove_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -1,16 +0,0 @@
meta:
name: Create Custom Section with Manual Books
type: http
seq: 2
http:
method: POST
url: '{{base_url}}/api/collections'
auth: inherit
body:
type: json
data: "{\n \"library_id\": \"{{library_id}}\",\n \"name\": \"My Favorites\",\n \"icon\": \"⭐\",\n \"description\": \"My favorite books\",\n \"show_on_dashboard\": true,\n \"auto_assign_rules\": \"\",\n \"manual_book_ids\": [\"{{media_item_id}}\"],\n \"match_type\": \"all\"\n}"
runtime:
scripts:
- type: after-response
code: "function onResponse(res) {\ntest(\"status must be 201 with valid request\", function() {\n expect(res.status).to.eql(201);\n});\n\ntest(\"response contains collection id\", function() {\n expect(res.body.id).to.exist;\nexpect(res.body.id).to.be.a('string');\n });\n\ntest(\"response contains collection name\", function() {\n expect(res.body.name).to.eql(\"My Favorites\");\n });\n}"
docs: "Create a custom collection with manually selected books.\n\n**Endpoint**: POST /api/collections\n**Auth**: Required (Bearer token)\n\n## Example Request\n\n```json\n{\n \"library_id\": \"cc23c3a7-f8fb-451a-a78d-2a16df1b725a\",\n \"name\": \"My Favorites\",\n \"icon\": \"⭐\",\n \"description\": \"My favorite books\",\n \"show_on_dashboard\": true,\n \"auto_assign_rules\": \"\",\n \"manual_book_ids\": [\"book-id-1\", \"book-id-2\"],\n \"match_type\": \"all\"\n}\n```\n\n## Response\n\nReturns the created collection with generated ID."
@@ -1,19 +0,0 @@
meta:
name: Create Custom Section Missing Required Fields
type: http
seq: 3
http:
method: POST
url: '{{base_url}}/api/collections'
auth: inherit
body:
type: json
data: "{\n \"library_id\": \"{{library_id}}\",\n \"icon\": \"\U0001F4DA\"\n}"
runtime:
scripts:
- type: after-response
code: "function onResponse(res) {\ntest(\"status must be 400 with missing name\", function() {\n expect(res.status).to.eql(400);\n});\n\ntest(\"response contains error message\", function() {\n expect(res.body.error).to.exist;\n });\n}"
docs: 'Test error handling when creating a collection without required fields.
**Expected Response**: 400 Bad Request with error message.'
@@ -1,17 +0,0 @@
meta:
name: Create Custom Section with Filter Rules
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/collections'
auth: inherit
body:
type: json
data: "{\n \"library_id\": \"{{library_id}}\",\n \"name\": \"Science Fiction Books\",\n \"icon\": \"\U0001F680\",\n \"description\": \"All my sci-fi books\",\n \"show_on_dashboard\": true,\n \"auto_assign_rules\": \"{\\\"rules\\\":[{\\\"id\\\":\\\"rule1\\\",\\\"field\\\":\\\"genre\\\",\\\"operator\\\":\\\"equals\\\",\\\"value\\\":\\\"Sci-Fi\\\",\\\"priority\\\":1}]}\",\n \"manual_book_ids\": [],\n \"match_type\": \"all\"\n}"
runtime:
scripts:
- type: after-response
code: "function onResponse(res) {\ntest(\"status must be 201 with valid request\", function() {\n expect(res.status).to.eql(201);\n});\n\ntest(\"response contains collection id\", function() {\n expect(res.body.id).to.exist;\nexpect(res.body.id).to.be.a('string');\n });\n\ntest(\"response contains collection name\", function() {\n expect(res.body.name).to.eql(\"Science Fiction Books\");\n });\n\ntest(\"response contains is_system false\", function() {\n expect(res.body.is_system).to.exist;\n expect(res.body.is_system).to.eql(false);\n });\n}"
docs: "Create a custom collection with filter rules.\n\n**Endpoint**: POST /api/collections\n**Auth**: Required (Bearer token)\n\n## Request Body\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| library_id | string | Yes | Library UUID |\n| name | string | Yes | Collection name |\n| icon | string | No | Emoji icon |\n| description | string | No | Collection description |\n| show_on_dashboard | boolean | No | Show on dashboard (default: true) |\n| auto_assign_rules | string | No | JSON string of filter rules |\n| manual_book_ids | array | No | Array of book IDs |\n| match_type | string | No | \"all\" (AND) or \"any\" (OR) |\n\n## Example Request\n\n```json\n{\n \"library_id\": \"cc23c3a7-f8fb-451a-a78d-2a16df1b725a\",\n \"name\": \"Science Fiction Books\",\n \"icon\": \"\U0001F680\",\n \"description\": \"All my sci-fi books\",\n \"show_on_dashboard\": true,\n \"auto_assign_rules\": \"{\\\"rules\\\":[{\\\"id\\\":\\\"rule1\\\",\\\"field\\\":\\\
\"genre\\\",\\\"operator\\\":\\\"equals\\\",\\\"value\\\":\\\"Sci-Fi\\\",\\\"priority\\\":1}]}\",\n \"manual_book_ids\": [],\n \"match_type\": \"all\"\n}\n```\n\n## Response\n\nReturns the created collection with generated ID."
+35
View File
@@ -0,0 +1,35 @@
info:
name: Create Collection
type: http
seq: 5
http:
method: POST
url: '{{base_url}}/api/collections'
auth: inherit
body:
type: json
data: |-
{
"name": "To Read",
"description": "Books I want to read soon",
"color": "#00FF00",
"icon": "📖",
"collection_type": "static",
"auto_assign_rules": [],
"view_settings": { "kobo": { "view_mode": "grid" } }
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs:
content: |-
# Create Collection
`POST /api/collections` — auth: jwt.
Canonical reference: docs/developer/api/collections/create_collection.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,39 @@
info:
name: Create Shelf Mapping
type: http
seq: 6
http:
method: POST
url: '{{base_url}}/api/devices/{{device_id}}/collections'
auth: inherit
body:
type: json
data: |-
{
"collection_id": "550e8400-e29b-41d4-a716-446655440000",
"device_shelf_name": "Sci-Fi",
"sync_direction": "bidirectional"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 201", function() {
expect(res.status).to.eql(201);
});
docs:
content: |-
# Create Shelf Mapping
`POST /api/devices/:id/collections` — auth: jwt.
Canonical reference: docs/developer/api/collections/create_shelf_mapping.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: Delete Collection
type: http
seq: 7
http:
method: DELETE
url: '{{base_url}}/api/collections/{{collection_id}}'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 204", function() {
expect(res.status).to.eql(204);
});
docs:
content: |-
# Delete Collection
`DELETE /api/collections/:id` — auth: jwt.
Canonical reference: docs/developer/api/collections/delete_collection.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,31 @@
info:
name: Delete Shelf Mapping
type: http
seq: 8
http:
method: DELETE
url: '{{base_url}}/api/devices/{{device_id}}/collections/{{collection_id}}'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 204", function() {
expect(res.status).to.eql(204);
});
docs:
content: |-
# Delete Shelf Mapping
`DELETE /api/devices/:id/collections/:collectionId` — auth: jwt.
Canonical reference: docs/developer/api/collections/delete_shelf_mapping.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: Get Collection
type: http
seq: 9
http:
method: GET
url: '{{base_url}}/api/collections/{{collection_id}}'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Collection
`GET /api/collections/:id` — auth: jwt.
Canonical reference: docs/developer/api/collections/get_collection.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,31 @@
info:
name: Get Book Collections
type: http
seq: 10
http:
method: GET
url: '{{base_url}}/api/collections/{{collection_id}}/books'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Book Collections
`GET /api/collections/:id/books` — auth: jwt.
Canonical reference: docs/developer/api/collections/get_collection_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: List Collections
type: http
seq: 11
http:
method: GET
url: '{{base_url}}/api/collections'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# List Collections
`GET /api/collections` — auth: jwt.
Canonical reference: docs/developer/api/collections/list_collections.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
-16
View File
@@ -1,16 +0,0 @@
meta:
name: Preview Collection
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/collections/preview'
auth: inherit
body:
type: json
data: "{\n \"library_id\": \"{{library_id}}\",\n \"rules\": [\n{\n \"id\": \"rule1\",\n \"field\": \"genre\",\n\"operator\": \"equals\",\n \"value\": \"Fiction\",\n\"priority\": 1\n }\n ],\n \"manual_book_ids\": [],\n \"limit\": 20\n}"
runtime:
scripts:
- type: after-response
code: "function onResponse(res) {\ntest(\"status must be 200 with valid request\", function() {\n expect(res.status).to.eql(200);\n});\n\ntest(\"response contains items array\", function() {\n expect(res.body.items).to.exist;\nexpect(res.body.items).to.be.an('array');\n });\n}"
docs: "Preview collection with filter rules before saving.\n\n**Endpoint**: POST /api/collections/preview\n**Auth**: Required (Bearer token)\n\n## Request Body\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| library_id | string | Yes | Library UUID |\n| rules | array | No | Filter rules to evaluate |\n| manual_book_ids | array | No | Manually selected book IDs |\n| limit | int | No | Max items to return (default: 20, max: 100) |\n\n## Example Request\n\n```json\n{\n \"library_id\": \"cc23c3a7-f8fb-451a-a78d-2a16df1b725a\",\n \"rules\": [\n {\n \"id\": \"rule1\",\n \"field\": \"genre\",\n \"operator\": \"equals\",\n \"value\": \"Fiction\",\n \"priority\": 1\n }\n ],\n \"manual_book_ids\": [],\n \"limit\": 20\n}\n```\n\n## Response\n\nReturns array of matching book items.\n\nThis endpoint is used by the Custom Section Builder to show users what books will match their filter rules before they save the collection."
+32
View File
@@ -0,0 +1,32 @@
info:
name: Preview Collection
type: http
seq: 12
http:
method: POST
url: '{{base_url}}/api/collections/preview'
auth: inherit
body:
type: json
data: |-
{
"library_id": "550e8400-e29b-41d4-a716-446655440000",
"rules": [],
"manual_book_ids": [],
"limit": 20
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs:
content: |-
# Preview Collection
`POST /api/collections/preview` — auth: jwt.
Canonical reference: docs/developer/api/collections/preview_collection.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,31 @@
info:
name: Remove Auto-Assign Rule
type: http
seq: 13
http:
method: DELETE
url: '{{base_url}}/api/collections/{{collection_id}}/rules/{{rule_id}}'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 204", function() {
expect(res.status).to.eql(204);
});
docs:
content: |-
# Remove Auto-Assign Rule
`DELETE /api/collections/:id/rules/:ruleId` — auth: jwt.
Canonical reference: docs/developer/api/collections/remove_auto_assign_rule.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+32
View File
@@ -0,0 +1,32 @@
info:
name: Reorder Collection Items
type: http
seq: 14
http:
method: PUT
url: '{{base_url}}/api/collections/{{collection_id}}/items/order'
auth: inherit
body:
type: json
data: |-
{
"item_ids": [
"550e8400-e29b-41d4-a716-446655440000",
"660e8400-e29b-41d4-a716-446655440001"
]
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs:
content: |-
# Reorder Collection Items
`PUT /api/collections/:id/items/order` — auth: jwt.
Canonical reference: docs/developer/api/collections/reorder_items.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -1,18 +0,0 @@
info:
name: Bulk Add Books to Collections
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/collections/bulk-add-books'
auth: inherit
body:
type: json
data: "{\n \"operations\": [\n {\n \"collection_id\": \"{{collectionId1"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Bulk Add Books to Collections\n\n Adds multiple books to multiple collections in a single request. Each operation specifies a collection and a list of books to add.\n\n **Method:** POST\n\n **Endpoint:** /api/collections/bulk-add-books\n\n **Authentication:** Bearer token\n\n **Request Body:**\n - `operations` (array): Array of collection-book operations\n - `collection_id` (string): Collection UUID\n - `book_ids` (array): Array of book UUIDs to add to the collection\n\n **Response:**\n - `results` (array): Results for each operation\n - `total` (number): Total number of operations\n - `success` (number): Number of successful operations\n - `failed` (number): Number of failed operations\n\n **Status Codes:**\n - 200: Success (with partial results if some failed)\n - 400: Invalid request data\n - 401: Unauthorized\n - 403: Forbidden\n - 500: Internal server error\n\n **Example:**\n ```json\n {\n \"operations\": [\n {\n \"collection_id\":\
\ \"collection-uuid-1\",\n \"book_ids\": [\"book-1\", \"book-2\"]"
@@ -1,15 +0,0 @@
info:
name: Bulk Remove Books - All Books
type: http
seq: 5
http:
method: POST
url: '{{base_url}}/api/collections/{{collection_id}}/books/bulk-remove'
auth: inherit
body:
type: json
data: "{\n \"book_ids\": [\n \"{{bookId1"
headers:
- key: Content-Type
value: application/json
docs: "## Bulk Remove Books from Collection\n\n Removes multiple books from a collection in a single request.\n\n **Method:** POST\n\n **Endpoint:** /api/collections/{collection_id"
@@ -1,17 +0,0 @@
info:
name: Bulk Remove Books - Empty List
type: http
seq: 2
http:
method: POST
url: '{{base_url}}/api/collections/{{collection_id}}/books/bulk-remove'
auth: inherit
body:
type: json
data: "{\n \"book_ids\": []"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Bulk Remove Books - Empty List Validation\n\n Tests validation behavior when providing an empty book_ids array.\n\n **Expected Result:** 400 Bad Request\n\n **Validation Rule:** book_ids array must contain at least one book UUID.\n\n **Purpose:** Ensures the API properly validates input and rejects empty removal requests."
@@ -1,17 +0,0 @@
info:
name: Bulk Remove Books - Invalid IDs
type: http
seq: 4
http:
method: POST
url: '{{base_url}}/api/collections/{{collection_id}}/books/bulk-remove'
auth: inherit
body:
type: json
data: "{\n \"book_ids\": [\n \"{{bookId1"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Bulk Remove Books - Invalid IDs\n\n Tests behavior when the book_ids array contains invalid UUID formats or non-existent books.\n\n **Expected Result:** 200 OK with partial success\n\n **Purpose:** Verifies that:\n - Invalid UUID formats don't crash the endpoint\n - Non-existent book IDs are handled gracefully\n - Valid IDs in the same request are still processed\n - Response includes detailed results showing which succeeded/failed\n\n **Note:** The endpoint should process all valid IDs and report failures for invalid ones, allowing clients to handle partial failures appropriately."
@@ -1,17 +0,0 @@
info:
name: Bulk Remove Books - Single Book
type: http
seq: 3
http:
method: POST
url: '{{base_url}}/api/collections/{{collection_id}}/books/bulk-remove'
auth: inherit
body:
type: json
data: "{\n \"book_ids\": [\n \"{{bookId1"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Bulk Remove Books - Single Book\n\n Tests that bulk remove endpoint works correctly with a single book.\n\n **Expected Result:** 200 OK with removed: 1, total: 1\n\n **Purpose:** Verifies the bulk remove endpoint handles single-item arrays correctly, providing flexibility for clients to use the same endpoint for both single and multiple removals.\n\n **Note:** Using bulk remove for a single book is functionally equivalent to the single remove endpoint but allows for consistent error handling and response format."
@@ -1,17 +0,0 @@
info:
name: Test Collection Rules - Author Contains
type: http
seq: 2
http:
method: POST
url: '{{base_url}}/api/collections/test-rules'
auth: inherit
body:
type: json
data: "{\n \"rules\": [\n {\n \"field\": \"author\",\n \"operator\": \"contains\",\n \"value\": \"Asimov\""
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Test Collection Rules - Author Contains\n\n Tests the \"contains\" operator on the author field to find books by a specific author (partial match).\n\n **Example Use Case:** Finding all books by an author whose name contains \"Asimov\" (e.g., \"Isaac Asimov\").\n\n **Operator:** `contains` - Matches if the field contains the specified value as a substring (case-insensitive typically).\n\n **Expected Result:** Returns all books where the author field contains \"Asimov\".\n\n **Purpose:** Demonstrates text-based partial matching for author searches, useful when you don't need the exact author name or want to find books by authors with similar names."
@@ -1,17 +0,0 @@
info:
name: Test Collection Rules - Copyright Year Greater Than
type: http
seq: 3
http:
method: POST
url: '{{base_url}}/api/collections/test-rules'
auth: inherit
body:
type: json
data: "{\n \"rules\": [\n {\n \"field\": \"copyright_year\",\n \"operator\": \"greater_than\",\n \"value\": \"2000\""
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Test Collection Rules - Copyright Year Greater Than\n\n Tests the \"greater_than\" operator on the copyright_year field to find books published after a specific year.\n\n **Example Use Case:** Creating a \"Modern Books\" collection with books published after 2000.\n\n **Operator:** `greater_than` - Matches if the field value is greater than the specified value (numeric comparison).\n\n **Field:** `copyright_year` - The year the book was copyrighted/published.\n\n **Expected Result:** Returns all books with copyright_year greater than 2000 (i.e., published in 2001 or later).\n\n **Purpose:** Demonstrates numeric comparison operators for creating date-based collections, useful for organizing books by publication era."
@@ -1,17 +0,0 @@
info:
name: Test Collection Rules - Empty Rules Array
type: http
seq: 5
http:
method: POST
url: '{{base_url}}/api/collections/test-rules'
auth: inherit
body:
type: json
data: "{\n \"rules\": []"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Test Collection Rules - Empty Rules Array\n\n Tests validation behavior when providing an empty rules array.\n\n **Expected Result:** 400 Bad Request\n\n **Validation Rule:** rules array must contain at least one rule object.\n\n **Purpose:** Ensures the API properly validates input and rejects empty rule sets, preventing accidental queries that would return all books or cause performance issues.\n\n **Use Case:** Client-side validation should prevent sending empty rules, but the API should also validate to catch malformed requests."
@@ -1,17 +0,0 @@
info:
name: Test Collection Rules - No Matches
type: http
seq: 4
http:
method: POST
url: '{{base_url}}/api/collections/test-rules'
auth: inherit
body:
type: json
data: "{\n \"rules\": [\n {\n \"field\": \"genre\",\n \"operator\": \"equals\",\n \"value\": \"NonExistentGenre123456\""
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Test Collection Rules - No Matches\n\n Tests behavior when collection rules don't match any books in the library.\n\n **Example Use Case:** Validating that a new genre name doesn't exist before creating a collection for it, or testing edge cases.\n\n **Expected Result:** 200 OK with empty matches array and total: 0\n\n **Purpose:** Verifies that the API handles zero-match scenarios gracefully:\n - Returns 200 (success) not 404\n - Returns empty array, not null\n - Returns total: 0 for clarity\n - No errors thrown for no results\n\n **Note:** An empty result set is a valid response and doesn't indicate an error. This allows users to test rules confidently before creating collections."
@@ -1,18 +0,0 @@
info:
name: Test Collection Rules
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/collections/test-rules'
auth: inherit
body:
type: json
data: "{\n \"rules\": [\n {\n \"field\": \"genre\",\n \"operator\": \"equals\",\n \"value\": \"Science Fiction\""
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Test Collection Rules\n\n Tests collection rules against the library to see which books match, without creating a collection. Useful for previewing what books would be included in a collection with specific rules.\n\n **Method:** POST\n\n **Endpoint:** /api/collections/test-rules\n\n **Authentication:** Bearer token\n\n **Request Body:**\n - `rules` (array): Array of rule objects to test\n - `field` (string): Field to test (genre, author, copyright_year, tags, etc.)\n - `operator` (string): Comparison operator\n - `equals`: Exact match\n - `contains`: Contains substring (for text fields)\n - `greater_than`: Greater than (for numeric fields)\n - `less_than`: Less than (for numeric fields)\n - `not_equals`: Not equal to\n - `starts_with`: Starts with\n - `ends_with`: Ends with\n - `is_empty`: Field is empty or null\n - `is_not_empty`: Field is not empty and not null\n - `value` (string): Value to compare against (not required\
\ for is_empty/is_not_empty)\n\n **Response:**\n - `matches` (array): Array of matching books\n - `id` (string): Book UUID\n - `title` (string): Book title\n - `author` (string): Book author\n - `genre` (string): Book genre\n - Additional book metadata\n - `total` (number): Total number of matching books\n\n **Status Codes:**\n - 200: Success - returns matching books\n - 400: Invalid request (empty rules array, invalid field/operator)\n - 401: Unauthorized\n - 500: Internal server error\n\n **Example Request:**\n ```json\n {\n \"rules\": [\n {\n \"field\": \"genre\",\n \"operator\": \"equals\",\n \"value\": \"Science Fiction\""
+48
View File
@@ -0,0 +1,48 @@
info:
name: Test Rule
type: http
seq: 15
http:
method: POST
url: '{{base_url}}/api/collections/test-rules'
auth: inherit
body:
type: json
data: |-
{
"rules": [
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction"
},
{
"field": "author",
"operator": "contains",
"value": "Asimov"
}
]
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Test Rule
`POST /api/collections/test-rules` — auth: jwt.
Canonical reference: docs/developer/api/collections/test_rule.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+36
View File
@@ -0,0 +1,36 @@
info:
name: Update Collection
type: http
seq: 16
http:
method: PUT
url: '{{base_url}}/api/collections/{{collection_id}}'
auth: inherit
body:
type: json
data: |-
{
"name": "To Read (2026)",
"description": "Updated description",
"color": "#33CCFF",
"icon": "📚",
"auto_assign_rules": [],
"view_settings": { "hide_finished": true },
"hide_finished": ["true"],
"show_up_next": ["true"]
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs:
content: |-
# Update Collection
`PUT /api/collections/:id` — auth: jwt.
Canonical reference: docs/developer/api/collections/update_collection.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+29
View File
@@ -0,0 +1,29 @@
info:
name: Update Collection Rule
type: http
seq: 17
http:
method: PUT
url: '{{base_url}}/api/collections/{{collection_id}}/rules/{{rule_id}}'
auth: inherit
body:
type: json
data: |-
{
"enabled": false
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs:
content: |-
# Update Collection Rule
`PUT /api/collections/:id/rules/:ruleId` — auth: jwt.
Canonical reference: docs/developer/api/collections/update_rule.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
-22
View File
@@ -1,22 +0,0 @@
info:
name: Delete Conflict
type: http
seq: 4
http:
method: DELETE
url: '{{base_url}}/api/conflicts/{{conflict_id}}'
auth: inherit
body:
type: none
headers:
- key: Authorization
value: Bearer {{token
docs: |-
## Delete Conflict
Permanently deletes a specific conflict record from the system.
**Method:** DELETE
**Endpoint:** /api/conflicts/{conflict_id
-37
View File
@@ -1,37 +0,0 @@
info:
name: Dismiss All Resolved Conflicts
type: http
seq: 5
http:
method: POST
url: '{{base_url}}/api/conflicts/dismiss-all'
auth: inherit
body:
type: none
headers:
- key: Authorization
value: Bearer {{token
docs: |-
## Dismiss All Resolved Conflicts
Deletes all resolved conflicts for the authenticated user, cleaning up the conflict list.
**Method:** POST
**Endpoint:** /api/conflicts/dismiss-all
**Authentication:** Bearer token
**Response:**
- `deleted` (number): Number of conflict records that were deleted
**Status Codes:**
- 200: Success - conflicts deleted
- 401: Unauthorized
- 500: Internal server error
**Example Response:**
```json
{
"deleted": 5
-22
View File
@@ -1,22 +0,0 @@
info:
name: Get Conflict Details
type: http
seq: 2
http:
method: GET
url: '{{base_url}}/api/conflicts/{{conflict_id}}'
auth: inherit
body:
type: none
headers:
- key: Authorization
value: Bearer {{token
docs: |-
## Get Conflict Details
Retrieves detailed information about a specific conflict, including side-by-side comparison of conflicting data from all sources.
**Method:** GET
**Endpoint:** /api/conflicts/{conflict_id
-74
View File
@@ -1,74 +0,0 @@
info:
name: List Conflicts
type: http
seq: 1
http:
method: GET
url: '{{base_url}}/api/conflicts?status=unresolved'
auth: inherit
body:
type: none
headers:
- key: Authorization
value: Bearer {{token
docs: |-
## List Conflicts
Lists all sync conflicts for the authenticated user with optional filtering.
**Method:** GET
**Endpoint:** /api/conflicts
**Authentication:** Bearer token
**Query Parameters:**
- `status` (string, optional): Filter by resolution status
- `unresolved`: Only unresolved conflicts (default)
- `user_resolved`: Conflicts resolved by user
- `auto_resolved`: Automatically resolved conflicts
- `all`: All conflicts regardless of status
- `type` (string, optional): Filter by conflict type
- `progress`: Reading progress conflicts
- `note`: Bookmark/note conflicts
- `highlight`: Highlight conflicts
**Response:**
- `conflicts` (array): Array of conflict objects
- `total` (number): Total number of conflicts matching filters
- `unresolved` (number): Number of unresolved conflicts
**Each Conflict Object:**
- `id` (string): Conflict UUID
- `media_item_id` (string): Associated book UUID
- `media_item_title` (string): Book title
- `conflict_type` (string): Type of conflict (progress, note, highlight)
- `conflict_data` (object): Side-by-side comparison of conflicting data
- `koreader`: Data from KOReader device
- `kobo`: Data from Kobo device
- `web`: Data from web interface
- `resolution_status` (string): Current status (unresolved, user_resolved, auto_resolved)
- `created_at` (string): ISO 8601 timestamp when conflict was detected
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 500: Internal server error
**Example Request:**
```
GET /api/conflicts?status=unresolved&type=progress
```
**Example Response:**
```json
{
"conflicts": [
{
"id": "conflict-uuid",
"media_item_id": "book-uuid",
"media_item_title": "Foundation",
"conflict_type": "progress",
"conflict_data": {
"koreader": { "percentage": 0.65, "epubcfi": "..."
-15
View File
@@ -1,15 +0,0 @@
info:
name: Resolve Conflict
type: http
seq: 3
http:
method: POST
url: '{{base_url}}/api/conflicts/{{conflict_id}}/resolve'
auth: inherit
body:
type: json
data: "{\n \"winner\": \"koreader\",\n \"manual_data\": null,\n \"apply_to_all_future_conflicts\": false,\n \"reason\": \"User chose more recent progress\""
headers:
- key: Authorization
value: Bearer {{token
docs: "## Resolve Conflict\n\n Resolves a sync conflict by choosing which source to use for the conflicting data.\n\n **Method:** POST\n\n **Endpoint:** /api/conflicts/{conflict_id"
@@ -0,0 +1,40 @@
info:
name: Bulk Dismiss Conflicts
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/conflicts/bulk-dismiss'
auth: inherit
body:
type: json
data: |-
{
"conflict_ids": [
"550e8400-e29b-41d4-a716-446655440000",
"660e8400-e29b-41d4-a716-446655440001"
]
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Bulk Dismiss Conflicts
`POST /api/conflicts/bulk-dismiss` — auth: jwt.
Canonical reference: docs/developer/api/conflicts/bulk_dismiss_conflicts.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,41 @@
info:
name: Bulk Resolve Conflicts
type: http
seq: 2
http:
method: POST
url: '{{base_url}}/api/conflicts/bulk-resolve'
auth: inherit
body:
type: json
data: |-
{
"conflict_ids": [
"550e8400-e29b-41d4-a716-446655440000",
"660e8400-e29b-41d4-a716-446655440001"
],
"strategy": "highest_progress"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Bulk Resolve Conflicts
`POST /api/conflicts/bulk-resolve` — auth: jwt.
Canonical reference: docs/developer/api/conflicts/bulk_resolve_conflicts.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: Delete Conflict
type: http
seq: 3
http:
method: DELETE
url: '{{base_url}}/api/conflicts/{{media_item_id}}'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 204", function() {
expect(res.status).to.eql(204);
});
docs:
content: |-
# Delete Conflict
`DELETE /api/conflicts/:id` — auth: jwt.
Canonical reference: docs/developer/api/conflicts/delete_conflict.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: Dismiss All Resolved
type: http
seq: 4
http:
method: POST
url: '{{base_url}}/api/conflicts/dismiss-all'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Dismiss All Resolved
`POST /api/conflicts/dismiss-all` — auth: jwt.
Canonical reference: docs/developer/api/conflicts/dismiss_all_resolved.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: Get Conflict Details
type: http
seq: 5
http:
method: GET
url: '{{base_url}}/api/conflicts/{{media_item_id}}'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Conflict Details
`GET /api/conflicts/:id` — auth: jwt.
Canonical reference: docs/developer/api/conflicts/get_conflict.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: List Conflicts
type: http
seq: 6
http:
method: GET
url: '{{base_url}}/api/conflicts'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# List Conflicts
`GET /api/conflicts` — auth: jwt.
Canonical reference: docs/developer/api/conflicts/list_conflicts.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+37
View File
@@ -0,0 +1,37 @@
info:
name: Resolve Conflict
type: http
seq: 7
http:
method: POST
url: '{{base_url}}/api/conflicts/{{media_item_id}}/resolve'
auth: inherit
body:
type: json
data: |-
{
"winner": "kobo"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Resolve Conflict
`POST /api/conflicts/:id/resolve` — auth: jwt.
Canonical reference: docs/developer/api/conflicts/resolve_conflict.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -1,17 +0,0 @@
info:
name: Bulk Dismiss Conflicts
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/conflicts/bulk-dismiss'
auth: inherit
body:
type: json
data: "{\n \"conflict_ids\": [\n \"{{conflictId1"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Bulk Dismiss Conflicts\n\n Dismisses multiple sync conflicts without resolving them. This removes them from the conflict list while leaving the data unchanged.\n\n **Method:** POST\n\n **Endpoint:** /api/conflicts/bulk-dismiss\n\n **Authentication:** Bearer token\n\n **Request Body:**\n - `conflict_ids` (array): Array of conflict UUIDs to dismiss\n\n **Response:**\n - `results` (array): Results for each dismissal\n - `total` (number): Total number of conflicts processed\n - `success` (number): Number of successfully dismissed conflicts\n - `failed` (number): Number of failed dismissals\n\n **Status Codes:**\n - 200: Success\n - 400: Invalid request data\n - 401: Unauthorized\n - 403: Forbidden\n - 404: One or more conflicts not found\n - 500: Internal server error\n\n **Example:**\n ```json\n {\n \"conflict_ids\": [\"uuid-1\", \"uuid-2\"]"
@@ -1,18 +0,0 @@
info:
name: Bulk Resolve Conflicts
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/conflicts/bulk-resolve'
auth: inherit
body:
type: json
data: "{\n \"conflict_ids\": [\n \"{{conflictId1"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Bulk Resolve Conflicts\n\n Resolves multiple sync conflicts in a single request using a specified resolution strategy.\n\n **Method:** POST\n\n **Endpoint:** /api/conflicts/bulk-resolve\n\n **Authentication:** Bearer token\n\n **Request Body:**\n - `conflict_ids` (array): Array of conflict UUIDs to resolve\n - `strategy` (string): Resolution strategy\n - `most_recent`: Use the most recently updated progress\n - `highest_progress`: Use the reading progress with the highest percent read\n - `server`: Always prefer server-side data\n - `device`: Always prefer device-side data\n\n **Response:**\n - `results` (array): Results for each conflict resolution\n - `total` (number): Total number of conflicts processed\n - `success` (number): Number of successfully resolved conflicts\n - `failed` (number): Number of failed resolutions\n\n **Status Codes:**\n - 200: Success (with partial results if some failed)\n - 400: Invalid request data\n - 401: Unauthorized\n\
\ - 403: Forbidden\n - 404: One or more conflicts not found\n - 500: Internal server error\n\n **Example:**\n ```json\n {\n \"conflict_ids\": [\"uuid-1\", \"uuid-2\", \"uuid-3\"],\n \"strategy\": \"most_recent\""
@@ -1,18 +0,0 @@
info:
name: Bulk Resolve with Highest Progress Strategy
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/conflicts/bulk-resolve'
auth: inherit
body:
type: json
data: "{\n \"conflict_ids\": [\n \"{{conflictId1"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Bulk Resolve with Highest Progress Strategy\n\n Resolves multiple sync conflicts using the \"highest_progress\" strategy, which keeps the reading progress with the highest percentage read.\n\n **Method:** POST\n\n **Endpoint:** /api/conflicts/bulk-resolve\n\n **Authentication:** Bearer token\n\n **Request Body:**\n - `conflict_ids` (array): Array of conflict UUIDs to resolve\n - `strategy` (string): Must be \"highest_progress\"\n\n **Response:**\n - `results` (array): Results for each conflict resolution\n - `total` (number): Total number of conflicts processed\n - `success` (number): Number of successfully resolved conflicts\n - `failed` (number): Number of failed resolutions\n\n **Status Codes:**\n - 200: Success\n - 400: Invalid request data\n - 401: Unauthorized\n - 403: Forbidden\n - 500: Internal server error\n\n **Note:** The highest progress strategy is ideal when you want to preserve the most reading progress across devices. Use this when you've been\
\ reading on multiple devices and want to keep the furthest position."
@@ -1,38 +0,0 @@
info:
name: Create Collection with Dashboard
type: http
seq: 4
http:
method: POST
url: '{{base_url}}/api/collections'
auth: inherit
runtime:
scripts:
- type: tests
code: "test(\"creates collection successfully\", function() {\n expect(res.status).to.eql(201);"
docs: |-
Create a new collection with dashboard visibility enabled.
**Endpoint**: POST /api/collections
**Auth**: Required (Bearer token)
## Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| name | string | Yes | Collection name |
| description | string | No | Collection description |
| color | string | No | Hex color code |
| icon | string | No | Icon emoji or name |
| show_on_dashboard | boolean | No | Show on dashboard (default: false) |
## Example Request
```json
{
"name": "My Favorites",
"description": "My favorite books",
"color": "#FF5733",
"icon": "⭐",
"show_on_dashboard": true
@@ -1,73 +0,0 @@
info:
name: Get Dashboard Sections
type: http
seq: 1
http:
method: GET
url: '{{base_url}}/api/dashboard/sections?library_id={{library_id}}'
auth: inherit
body:
type: none
runtime:
scripts:
- type: tests
code: "test(\"status must be 200 with auth\", function() {\n expect(res.status).to.eql(200);\n\
});\n\ntest(\"response contains sections array\", function() {\n expect(res.body.sections).to.exist;\n\
expect(res.body.sections).to.be.an('array');\n });"
docs: |-
Get all dashboard sections for a specific library.
**Endpoint**: GET /api/dashboard/sections
**Auth**: Required (Bearer token via auth: inherit)
## Query Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| library_id | string | Yes | Library UUID |
| limit | int | No | Items per section (default: 20, max: 100) |
## Response
Returns array of sections including:
- System collections (continue-reading, recently-added, recently-read, not-started)
- User collections marked with show_on_dashboard: true
## Section Structure
Each section has:
- `id`: Collection name (string)
- `is_system`: Boolean indicating if this is a system collection
- `title`: Display title
- `description`: Collection description
- `icon`: Emoji icon
- `items`: Array of BookInfo objects
- `view_all_url`: URL to view all items (system collections only)
- `priority`: Display order (lower numbers first)
## Example Response
```json
{
"sections": [
{
"id": "continue-reading",
"is_system": true,
"title": "Continue Reading",
"description": "Books you're currently reading (0 < progress < 1)",
"icon": "📖",
"items": [
{
"media_item_id": "uuid",
"title": "Book Title",
"author": "Author Name",
"cover_image_path": "/path/to/cover.jpg"
}
],
"view_all_url": "/section/continue-reading",
"priority": 1
}
]
}
```
@@ -1,30 +0,0 @@
info:
name: Get Dashboard Sections by Library
type: http
seq: 3
http:
method: GET
url: '{{base_url}}/api/dashboard/sections'
auth: inherit
runtime:
scripts:
- type: tests
code: "test(\"status must be 200\", function() {\n expect(res.status).to.eql(200);"
docs: |-
Get all dashboard sections for a specific library.
**Endpoint**: GET /api/dashboard/sections
**Auth**: Required (Bearer token)
## Query Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| library_id | string | Yes | Library UUID |
## Response
Returns array of sections including:
- Smart sections (continue-reading, in-progress, recently-added, etc.)
- User collections marked for dashboard
+31
View File
@@ -0,0 +1,31 @@
info:
name: Get Dashboard Preferences
type: http
seq: 1
http:
method: GET
url: '{{base_url}}/api/dashboard/preferences'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Dashboard Preferences
`GET /api/dashboard/preferences` — auth: jwt.
Canonical reference: docs/developer/api/dashboard/get_preferences.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: Get Dashboard Sections
type: http
seq: 2
http:
method: GET
url: '{{base_url}}/api/dashboard/sections'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Dashboard Sections
`GET /api/dashboard/sections` — auth: jwt.
Canonical reference: docs/developer/api/dashboard/get_sections.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -1,16 +0,0 @@
info:
name: Restore System Collection
type: http
seq: 3
http:
method: POST
url: '{{base_url}}/api/dashboard/restore-system-collection'
auth: inherit
body:
type: json
data: "{\n \"collection_name\": \"continue-reading\"\n}"
runtime:
scripts:
- type: tests
code: "test(\"status must be 200 with valid collection\", function() {\n expect(res.status).to.eql(200);\n});\n\ntest(\"response has success message\", function() {\n expect(res.body.message).to.exist;\n});"
docs: "Restore a system collection to its default state.\n\n**Endpoint**: POST /api/dashboard/restore-system-collection\n**Auth**: Required (Bearer token)\n\n## Request Body\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| collection_name | string | Yes | Name of system collection to restore |\n\nValid collection names:\n- `continue-reading`: Books you're currently reading (0 < progress < 1)\n- `recently-added`: Newly added items to this library\n- `recently-read`: Books you've finished (progress >= 1)\n- `not-started`: Books you haven't read yet (progress = 0 or no record)\n\n## Example Request\n\n```json\n{\n \"collection_name\": \"continue-reading\"\n}\n```\n\n## Response\n\nReturns success message on restore."
@@ -0,0 +1,30 @@
info:
name: Restore System Collection
type: http
seq: 3
http:
method: POST
url: '{{base_url}}/api/dashboard/restore-system-collection'
auth: inherit
body:
type: json
data: |-
{
"collection_name": "Continue Reading",
"reset_type": "full"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs:
content: |-
# Restore System Collection
`POST /api/dashboard/restore-system-collection` — auth: jwt.
Canonical reference: docs/developer/api/dashboard/restore_system_collection.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
Loaded 100 of 759 files, more files were not shown because too many files have changed in this diff. Show more