{"openapi":"3.0.3","info":{"title":"mytradingcardstorage API","version":"1.0.0","description":"REST API of the single-user, LAN-only card collection app. **No authentication.**\n\nThis file is the single source of truth for paths and parameters. It is served as\nSwagger UI at `/api/docs` and as JSON at `/api/openapi.json`. A test\n(`test/docs/openapi.test.js`) fails when a route exists that is not documented here\n(or the other way round) — update this file whenever you add or change an endpoint.\n\n**Every endpoint that reads or writes the database emits at least one structured log\nline** (pino, JSON, with a `route` field).\n\n### Editable card fields\n`first_name, last_name, team, sport, manufacturer, set_name, insert_name, number, year,\nvariations*, serial_number, production_run, tags*, condition, for_sale, storage, grade,\ngrading_company, grading_cert, notes, is_rookie†, has_autograph†, has_jersey†, is_die_cut†,\nfavorite†, is_graded†` — `*` array (JSON array or comma separated string), `†` boolean.\n`id`, `scribe_id` and `raw` can not be edited. Every write updates the column **and** the\noriginal `raw` blob and is recorded in the audit trail (`/api/changes`).\n"},"servers":[{"url":"/"}],"tags":[{"name":"Cards"},{"name":"Bulk edit & audit"},{"name":"Scan template"},{"name":"Settings"},{"name":"Scan & import"},{"name":"Maintenance"}],"paths":{"/api/cards":{"get":{"tags":["Cards"],"summary":"List / filter cards as JSON","description":"Works for any set. Exact filters are case-insensitive and trimmed. Use `GET /api/sets`\nfirst to discover the stored spelling of manufacturer / set / year. Ordered by `id`\nascending so paging is stable.\n","parameters":[{"name":"manufacturer","in":"query","schema":{"type":"string"},"example":"Topps"},{"name":"set","in":"query","description":"Exact `set_name`","schema":{"type":"string"},"example":"Heritage"},{"name":"year","in":"query","schema":{"type":"string"},"example":"2026"},{"name":"sport","in":"query","schema":{"type":"string"}},{"name":"team","in":"query","schema":{"type":"string"}},{"name":"insert","in":"query","description":"Exact `insert_name`","schema":{"type":"string"}},{"name":"number","in":"query","description":"Exact card number","schema":{"type":"string"}},{"name":"missing","in":"query","description":"Comma separated editable text columns that must ALL be blank, e.g. `number` (cards with no number) or `number,team`.","schema":{"type":"string"},"example":"number"},{"name":"q","in":"query","description":"Free text, same semantics as the grid search box (all tokens must match some column). A token of the form `/<n>` (e.g. `/99`) matches cards whose production run is exactly n and combines with the other tokens and flags, e.g. `q=panini playbook 2021 /99&auto=1`.","schema":{"type":"string"}},{"name":"auto","in":"query","description":"Quick filter (also: relic, graded, rookie, diecut, numbered, insert, variant, fixme). Accepts 1/true/on.","schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","default":100,"minimum":1,"maximum":500}},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0}}],"responses":{"200":{"description":"A page of cards (without the `raw` blob).","content":{"application/json":{"schema":{"type":"object","properties":{"total":{"type":"integer","description":"Matches over all pages"},"limit":{"type":"integer"},"offset":{"type":"integer"},"cards":{"type":"array","items":{"$ref":"#/components/schemas/Card"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"}}}},"/api/sets":{"get":{"tags":["Cards"],"summary":"List the distinct sets in the collection","description":"One row per manufacturer / set / year / sport with card count and how many cards still lack a number.","parameters":[{"name":"q","in":"query","description":"Tokens matched against manufacturer, set, year and sport.","schema":{"type":"string"},"example":"heritage 2026"}],"responses":{"200":{"description":"Sets","content":{"application/json":{"schema":{"type":"object","properties":{"sets":{"type":"array","items":{"type":"object","properties":{"manufacturer":{"type":"string","nullable":true},"set_name":{"type":"string","nullable":true},"year":{"type":"string","nullable":true},"sport":{"type":"string","nullable":true},"count":{"type":"integer"},"missing_number":{"type":"integer"}}}}}}}}}}}},"/api/cards/bulk":{"patch":{"tags":["Bulk edit & audit"],"summary":"Edit many cards in one all-or-nothing transaction","description":"Each entry is `{ id, fields }`. If any entry is invalid (unknown id, no editable field,\nduplicate id) the whole request is rejected with 422 and **nothing is written**.\nUse `dry_run` to preview. A real run returns a `batch_id` that can be passed to\n`POST /api/changes/revert`.\n","parameters":[{"name":"dry_run","in":"query","description":"true = report only, write nothing (also accepted as `dry_run` in the body)","schema":{"type":"boolean"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["changes"],"properties":{"source":{"type":"string","description":"Label stored in the audit trail","example":"ai-cleanup"},"dry_run":{"type":"boolean"},"changes":{"type":"array","maxItems":2000,"items":{"type":"object","required":["id","fields"],"properties":{"id":{"type":"integer"},"fields":{"type":"object","example":{"number":"123"}}}}}}}}}},"responses":{"200":{"description":"Applied (or previewed).","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"dry_run":{"type":"boolean"},"written":{"type":"boolean"},"batch_id":{"type":"string","nullable":true,"description":"null for a dry run"},"changed":{"type":"integer"},"unchanged":{"type":"integer"},"results":{"type":"array","items":{"type":"object","properties":{"id":{"type":"integer"},"status":{"type":"string","enum":["changed","unchanged"]},"changes":{"$ref":"#/components/schemas/FieldChanges"}}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"422":{"description":"At least one entry is invalid; nothing was written. `results[].status` is `invalid` or `not_found` for the offenders."}}}},"/api/cards/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"get":{"tags":["Cards"],"summary":"One card (full record incl. `raw`) with prev/next ids","description":"`prev_id` / `next_id` follow the grid order and honour `q` and the quick-filter flags.","parameters":[{"name":"q","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"The card","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CardDetail"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"$ref":"#/components/responses/NotFound"}}},"patch":{"tags":["Cards"],"summary":"Partially update one card","description":"Only the fields in the body are touched; every other column stays as it is.","parameters":[{"name":"source","in":"query","description":"Audit-trail label (default `ui`)","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","example":{"number":"123"}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"batch_id":{"type":"string"},"changes":{"$ref":"#/components/schemas/FieldChanges"},"fixme_fields":{"type":"array","items":{"type":"string"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"$ref":"#/components/responses/NotFound"}}},"delete":{"tags":["Cards"],"summary":"Delete a card and its image files","responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/api/changes":{"get":{"tags":["Bulk edit & audit"],"summary":"Audit trail (one row per changed field), newest first","parameters":[{"name":"batch_id","in":"query","schema":{"type":"string"}},{"name":"card_id","in":"query","schema":{"type":"integer"}},{"name":"source","in":"query","schema":{"type":"string"}},{"name":"field","in":"query","schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","default":100,"maximum":500}},{"name":"offset","in":"query","schema":{"type":"integer","default":0}}],"responses":{"200":{"description":"Changes","content":{"application/json":{"schema":{"type":"object","properties":{"total":{"type":"integer"},"limit":{"type":"integer"},"offset":{"type":"integer"},"changes":{"type":"array","items":{"type":"object","properties":{"id":{"type":"integer"},"batch_id":{"type":"string"},"card_id":{"type":"integer"},"scribe_id":{"type":"string","nullable":true},"field":{"type":"string"},"old_value":{"type":"string","nullable":true},"new_value":{"type":"string","nullable":true},"source":{"type":"string"},"changed_at":{"type":"string","format":"date-time"}}}}}}}}}}}},"/api/changes/revert":{"post":{"tags":["Bulk edit & audit"],"summary":"Undo a batch from the audit trail","description":"Restores each field to its old value **only** where the card still holds the value the\nbatch wrote; anything edited since is reported as `conflict` and left alone. The revert\nis itself audited under a new `batch_id`.\n","parameters":[{"name":"dry_run","in":"query","schema":{"type":"boolean"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["batch_id"],"properties":{"batch_id":{"type":"string"},"dry_run":{"type":"boolean"}}}}}},"responses":{"200":{"description":"Result per field (`reverted`, `conflict`, `card_missing`)","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"dry_run":{"type":"boolean"},"batch_id":{"type":"string","nullable":true},"reverted":{"type":"integer"},"skipped":{"type":"integer"},"results":{"type":"array","items":{"type":"object"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/api/backup":{"post":{"tags":["Maintenance"],"summary":"Create a consistent copy of the database","description":"Writes `data/backups/cards-<timestamp>.db` using SQLite's online backup (safe while the app is running).","responses":{"200":{"description":"Backup created","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"file":{"type":"string"},"bytes":{"type":"integer"}}}}}},"500":{"description":"Backup failed"}}}},"/api/search":{"get":{"tags":["Cards"],"summary":"Grid live-search (returns rendered HTML inside JSON — used by the UI)","description":"For scripts use `GET /api/cards` instead.","parameters":[{"name":"q","in":"query","schema":{"type":"string"}},{"name":"sort","in":"query","schema":{"type":"string","enum":["recent","earliest","year","name","set"]}},{"name":"offset","in":"query","schema":{"type":"integer"}}],"responses":{"200":{"description":"`resultsHtml` (offset 0) or `itemsHtml` (offset > 0) plus counts"}}}},"/api/template":{"get":{"tags":["Scan template"],"summary":"Read the scan template row","responses":{"200":{"description":"Template fields"}}},"patch":{"tags":["Scan template"],"summary":"Edit the scan template","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"description":"Saved"},"400":{"$ref":"#/components/responses/BadRequest"}}}},"/api/settings":{"get":{"tags":["Settings"],"summary":"Read global app settings and their definitions","responses":{"200":{"description":"`{ settings, definitions }`"}}},"patch":{"tags":["Settings"],"summary":"Update global app settings","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","example":{"cameraZoom":1.5}}}}},"responses":{"200":{"description":"`{ settings, definitions }`"},"400":{"$ref":"#/components/responses/BadRequest"}}}},"/api/changelog":{"get":{"tags":["Settings"],"summary":"Changelog, one entry per released version (newest first)","responses":{"200":{"description":"`[{ version, date, message }]` — date is the release commit's ISO timestamp","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"version":{"type":"string","example":"1.0.21"},"date":{"type":"string","example":"2026-11-07T09:40:57+01:00"},"message":{"type":"string","example":"extended AI skill context"}}}}}}}}}},"/api/scan":{"post":{"tags":["Scan & import"],"summary":"Extract card details from front/back photos with AI (no DB write)","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["front","back"],"properties":{"front":{"type":"string","description":"data:image/(jpeg|png|webp);base64,…"},"back":{"type":"string"},"mode":{"type":"string","description":"Capture mode, see scan/modes.js"}}}}}},"responses":{"200":{"description":"`{ fields }` with the scan template merged in"},"400":{"$ref":"#/components/responses/BadRequest"},"502":{"description":"The AI call failed"}}}},"/api/scan/save":{"post":{"tags":["Scan & import"],"summary":"Save a confirmed scan as a new card","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["fields","front","back"],"properties":{"fields":{"type":"object"},"front":{"type":"string"},"back":{"type":"string"}}}}}},"responses":{"200":{"description":"`{ ok, id, scribe_id }`"},"400":{"$ref":"#/components/responses/BadRequest"},"500":{"description":"Image write or insert failed"}}}},"/api/import":{"post":{"tags":["Scan & import"],"summary":"Import a SetScribe JSON export (streams NDJSON progress)","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"array","items":{"type":"object"}}}}},"responses":{"200":{"description":"`application/x-ndjson` stream of `progress` events, ending in `done`, `aborted` or `error`"}}}}},"components":{"responses":{"BadRequest":{"description":"Invalid request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotFound":{"description":"Unknown card / batch","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"schemas":{"Error":{"type":"object","properties":{"error":{"type":"string"}}},"FieldChanges":{"type":"object","description":"Only fields whose stored value actually changed, e.g. `{ \"number\": { \"old\": \"16\", \"new\": \"18\" } }`","additionalProperties":{"type":"object","properties":{"old":{},"new":{}}}},"Card":{"type":"object","properties":{"id":{"type":"integer"},"scribe_id":{"type":"string","nullable":true},"first_name":{"type":"string","nullable":true},"last_name":{"type":"string","nullable":true},"team":{"type":"string","nullable":true},"sport":{"type":"string","nullable":true},"manufacturer":{"type":"string","nullable":true},"set_name":{"type":"string","nullable":true},"insert_name":{"type":"string","nullable":true},"number":{"type":"string","nullable":true,"description":"TEXT — may be non-numeric (e.g. `T-12`)"},"year":{"type":"string","nullable":true},"variations":{"type":"string","description":"JSON array as text"},"tags":{"type":"string","description":"JSON array as text"},"is_rookie":{"type":"integer"},"has_autograph":{"type":"integer"},"has_jersey":{"type":"integer"},"is_die_cut":{"type":"integer"},"serial_number":{"type":"string","nullable":true},"production_run":{"type":"string","nullable":true},"condition":{"type":"string","nullable":true},"favorite":{"type":"integer"},"for_sale":{"type":"string","enum":["Not For Sale","For Sale","Sold"],"description":"Any other value is normalised (case-insensitive) to 'Not For Sale'."},"is_graded":{"type":"integer"},"grade":{"type":"string","nullable":true},"grading_company":{"type":"string","nullable":true},"grading_cert":{"type":"string","nullable":true},"storage":{"type":"string","nullable":true},"notes":{"type":"string","nullable":true},"image_front":{"type":"string","nullable":true,"description":"Relative path; served at /img/<path>, thumbnail at /thumbs/<path>"},"image_back":{"type":"string","nullable":true},"scan_date":{"type":"integer","nullable":true}}},"CardDetail":{"allOf":[{"$ref":"#/components/schemas/Card"},{"type":"object","properties":{"raw":{"type":"string","description":"Original SetScribe object as JSON text"},"prev_id":{"type":"integer","nullable":true},"next_id":{"type":"integer","nullable":true},"fixme_fields":{"type":"array","items":{"type":"string"}}}}]}}}}