Files
Synapse/src/main/frontend/worker/semantic_routes.cljs
Dasemu db7f1fac7d Fase 9 (2/N): daemon-hosted MCP server, retire electron/mcp_server.cljs
Completes Fase 9's "consolidar MCP" half: the daemon now serves its own
MCP endpoint at /mcp, exposing every semantic-routes operation as an MCP
tool against the daemon's authoritative graph, and the old Electron-local
MCP server is retired since it's now redundant.

New: src/main/frontend/worker/mcp_server.cljs
The CF-Worker's own MCP endpoint (deps/db-sync/worker/entry.mjs) generates
its tools from the OpenAPI document via @cloudflare/codemode's
openApiMcpServer, a package that only exists inside Cloudflare Workers,
plus a pile of ChatGPT-App-specific tooling (asset image resources,
upload_asset, Cognito protected-resource metadata) this daemon has no use
for -- none of that ports to Node. So this registers one tool per
operation by hand instead, using @modelcontextprotocol/sdk directly, the
same pattern electron/mcp_server.cljs already used for its own 6 tools
(registerTool with an explicit title/description/inputSchema/handler) --
just generated from semantic-routes' operation table and operation-docs
(now public) instead of writing 35 one-off registrations by hand, so the
MCP tool surface can't drift from the HTTP routes or the OpenAPI document.
Each tool's Zod input shape is assembled from path params (auto-derived
from the route's :param segments) plus a per-operation-id map of the
extra query/body fields; a handful of shared fragments (pagination,
time filters, the recursive block-tree shape, property-value/task-status
selectors) cover the repetition across list/create/update operations.

Each tool's handler makes a loopback HTTP call to this same daemon's own
/v1/semantic/* route, forwarding the /mcp request's own Authorization
header -- reusing the already-tested HTTP-layer logic (repo-error
validation, lock-owner assertion, response shaping) instead of a second
request path directly against the thread-api. Session/transport plumbing
(handle-post/get/delete-request, the transports-by-session-id map) is
adapted from electron/mcp_server.cljs's Fastify-based version: that one
gets req/res already unwrapped-to-Node-primitives via `.raw` with the
body pre-parsed by Fastify; this one is called straight from the daemon's
own raw http.createServer callback, so req/res already are what
StreamableHTTPServerTransport wants, and the body needs parsing here
instead. No separate auth check either -- the daemon's existing
bearer-token check on every route (request-authorized? in
db_worker_node.cljs) already gates /mcp before this is reached.

Wires POST/GET/DELETE /mcp into db_worker_node.cljs's make-server cond
chain, alongside the existing /v1/semantic/* branch.

Dependencies: adds @modelcontextprotocol/sdk and zod (zod/v3 -- the SDK
doesn't work with zod 4's default export, same constraint
electron/mcp_server.cljs already worked around) to the root package.json;
previously only resources/package.json (Electron's own bundle) had them.

Retired: electron/mcp_server.cljs and its wiring (electron/server.cljs's
initialize-mcp-routes, its 3 POST/GET/DELETE /mcp route registrations,
the :mcp-enabled? config field; components/server.cljs's "copy MCP URL"
popup link; components/settings.cljs's entire AI settings tab, which had
nothing in it besides the MCP-server toggle). Electron already talks to
the daemon (Fase 3) rather than driving itself locally, so a second,
separately-configured MCP surface bolted onto Electron's own local
automation API server (which otherwise stays -- /api is a distinct,
still-used concern) was redundant with the daemon's new one. Removed the
4 associated i18n keys (server/mcp-url-copied, settings/ai,
settings.ai/enable-mcp-server[-desc]) from all ~26 locale dicts.

New: src/test/frontend/worker/mcp_server_test.cljs
Connects a real @modelcontextprotocol/sdk client over HTTP to a live
daemon's /mcp endpoint: lists all registered tools, round-trips
createPage/getPage and a recursive captureToToday block tree, and checks
a tool call against a nonexistent page surfaces as MCP error content
rather than throwing. 4 tests, 11 assertions.

Verification: all 5 shadow-cljs targets compile with 0 warnings. Full
`bb dev:test` run: the only failures are the same 7 pre-existing,
sandboxed-network-related persist_db_test.cljs failures already
documented in the Fase 8 and Fase 9 (1/N) commits -- identical test names,
identical ECONNREFUSED/Invalid URL errors. Every other shard, including
all three daemon-HTTP worker test files sharing one test.js process
(db_worker_node_test.cljs, semantic_test.cljs, mcp_server_test.cljs),
passes clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 17:07:27 +02:00

401 lines
28 KiB
Clojure

(ns frontend.worker.semantic-routes
"Route table and OpenAPI document for the semantic API served by the
daemon (Fase 9). Ported from deps/db-sync/.../routes/semantic.cljs: that
version routes by :graph-id under a Cloudflare Worker Durable Object per
graph. This daemon is bound to a single repo per process (see
db_worker_node.cljs's `bound-repo`), so the :graph-id path segment and the
listGraphs operation are dropped -- every request implicitly targets the
daemon's own bound repo. OAuth2 is likewise dropped in favor of the
daemon's actual bearer-token session auth (see db-worker-node-auth)."
(:require [clojure.string :as string]
[flatland.ordered.map :refer [ordered-map]]
[reitit.core :as r]))
(def operations
[{:method "GET" :path "/v1/semantic/pages"
:handler :semantic/pages-list :operation-id "listPages" :success-status 200}
{:method "POST" :path "/v1/semantic/pages"
:handler :semantic/pages-create :operation-id "createPage" :success-status 201}
{:method "GET" :path "/v1/semantic/pages/:page-id/blocks"
:handler :semantic/pages-blocks :operation-id "listPageBlocks" :success-status 200}
{:method "GET" :path "/v1/semantic/pages/:page-id/references"
:handler :semantic/pages-references :operation-id "listPageReferences" :success-status 200}
{:method "DELETE" :path "/v1/semantic/pages/:page-id"
:handler :semantic/pages-delete :operation-id "deletePage" :success-status 204}
{:method "GET" :path "/v1/semantic/pages/:page-id"
:handler :semantic/pages-get :operation-id "getPage" :success-status 200}
{:method "PATCH" :path "/v1/semantic/pages/:page-id"
:handler :semantic/pages-update :operation-id "updatePage" :success-status 200}
{:method "GET" :path "/v1/semantic/blocks/:block-id"
:handler :semantic/blocks-get :operation-id "getBlock" :success-status 200}
{:method "PATCH" :path "/v1/semantic/blocks/:block-id"
:handler :semantic/blocks-update :operation-id "updateBlock" :success-status 200}
{:method "DELETE" :path "/v1/semantic/blocks/:block-id"
:handler :semantic/blocks-delete :operation-id "deleteBlock" :success-status 204}
{:method "POST" :path "/v1/semantic/block-moves"
:handler :semantic/blocks-move :operation-id "moveBlocks" :success-status 200}
{:method "POST" :path "/v1/semantic/blocks/:block-id/children"
:handler :semantic/blocks-insert-children :operation-id "insertBlockChildren" :success-status 201}
{:method "POST" :path "/v1/semantic/block-trees"
:handler :semantic/blocks-insert-tree :operation-id "insertBlockTree" :success-status 201}
{:method "PUT" :path "/v1/semantic/blocks/:block-id/properties/:property-id"
:handler :semantic/blocks-set-property :operation-id "setBlockProperty" :success-status 200}
{:method "DELETE" :path "/v1/semantic/blocks/:block-id/properties/:property-id"
:handler :semantic/blocks-delete-property :operation-id "deleteBlockProperty" :success-status 204}
{:method "POST" :path "/v1/semantic/block-properties/batch-set"
:handler :semantic/blocks-batch-set-property :operation-id "batchSetBlockProperty" :success-status 200}
{:method "POST" :path "/v1/semantic/block-properties/batch-delete"
:handler :semantic/blocks-batch-delete-property :operation-id "batchDeleteBlockProperty" :success-status 200}
{:method "POST" :path "/v1/semantic/capture"
:handler :semantic/capture :operation-id "captureToToday" :success-status 201}
{:method "GET" :path "/v1/semantic/tasks"
:handler :semantic/tasks-list :operation-id "listTasks" :success-status 200}
{:method "POST" :path "/v1/semantic/tasks"
:handler :semantic/tasks-create :operation-id "createTask" :success-status 201}
{:method "GET" :path "/v1/semantic/tags"
:handler :semantic/tags-list :operation-id "listTags" :success-status 200}
{:method "POST" :path "/v1/semantic/tags"
:handler :semantic/tags-create :operation-id "createTag" :success-status 201}
{:method "GET" :path "/v1/semantic/tags/:tag-id"
:handler :semantic/tags-get :operation-id "getTag" :success-status 200}
{:method "GET" :path "/v1/semantic/tags/:tag-id/objects"
:handler :semantic/tags-objects :operation-id "listTagObjects" :success-status 200}
{:method "PATCH" :path "/v1/semantic/tags/:tag-id"
:handler :semantic/tags-update :operation-id "updateTag" :success-status 200}
{:method "DELETE" :path "/v1/semantic/tags/:tag-id"
:handler :semantic/tags-delete :operation-id "deleteTag" :success-status 204}
{:method "GET" :path "/v1/semantic/properties"
:handler :semantic/properties-list :operation-id "listProperties" :success-status 200}
{:method "POST" :path "/v1/semantic/properties"
:handler :semantic/properties-create :operation-id "createProperty" :success-status 201}
{:method "GET" :path "/v1/semantic/properties/:property-id"
:handler :semantic/properties-get :operation-id "getProperty" :success-status 200}
{:method "PATCH" :path "/v1/semantic/properties/:property-id"
:handler :semantic/properties-update :operation-id "updateProperty" :success-status 200}
{:method "DELETE" :path "/v1/semantic/properties/:property-id"
:handler :semantic/properties-delete :operation-id "deleteProperty" :success-status 204}
{:method "GET" :path "/v1/semantic/assets"
:handler :semantic/assets-list :operation-id "listAssets" :success-status 200}
{:method "POST" :path "/v1/semantic/assets"
:handler :semantic/assets-create :operation-id "createAsset" :success-status 201}
{:method "GET" :path "/v1/semantic/assets/:asset-block-id"
:handler :semantic/assets-get :operation-id "getAsset" :success-status 200}
{:method "GET" :path "/v1/semantic/search"
:handler :semantic/search :operation-id "searchGraph" :success-status 200}])
(def operation-docs
"Public so worker.mcp-server can source MCP tool titles/descriptions from
the same place the OpenAPI document does, instead of a second copy that
could drift."
{"listPages" ["List pages" "Returns a cursor-paginated list of page blocks in the graph."]
"createPage" ["Create a page" "Creates a page block with the supplied title."]
"listPageBlocks" ["List a page's blocks" "Returns a cursor-paginated list of the page's top-level blocks, including each selected block's descendant tree."]
"listPageReferences" ["List references to a page" "Returns a cursor-paginated flat list of blocks and pages that reference the addressed page. Results are not recursively expanded."]
"getPage" ["Get a page" "Returns one page block by UUID without loading its block tree."]
"updatePage" ["Update a page" "Renames one page identified by its block UUID."]
"deletePage" ["Delete a page" "Deletes one page through Logseq's page deletion rules, including its block tree."]
"getBlock" ["Get a block" "Returns one block or page by UUID without loading an unbounded tree."]
"updateBlock" ["Update a block" "Replaces the title of one block identified by UUID."]
"deleteBlock" ["Delete a block" "Deletes one block and its descendants. Asset blocks are deleted through this same operation."]
"moveBlocks" ["Move blocks" "Moves one or more blocks together before, after, into the first-child position, or into the last-child position of a target block. Logseq preserves the blocks' outliner order."]
"insertBlockChildren" ["Insert child block trees" "Appends or prepends one or more recursive block trees as children of the addressed block."]
"insertBlockTree" ["Insert block trees" "Inserts one or more recursive block trees relative to an explicit target block."]
"setBlockProperty" ["Set a block property" "Idempotently adds or replaces one typed property value on one block in a DB graph. The property path value may be its UUID or ident. Never write legacy file-graph key:: value syntax into the block title."]
"deleteBlockProperty" ["Delete a block property" "Idempotently removes one property from one block. The property definition itself is not deleted."]
"batchSetBlockProperty" ["Set block properties in a batch" "Atomically adds or replaces multiple typed block-property values in a DB graph after validating every entry. Property IDs are UUIDs or idents; never use legacy file-graph key:: value syntax."]
"batchDeleteBlockProperty" ["Delete block properties in a batch" "Atomically removes multiple block-property values after validating every entry."]
"captureToToday" ["Capture to today's journal" "Creates today's journal page when necessary and appends the supplied recursive block trees to its end."]
"listTasks" ["List DB tasks" "Lists cursor-paginated DB Task objects using the Task class index, with optional status, priority, and title-content filters. Use this operation instead of graph search to retrieve tasks."]
"createTask" ["Create a DB task" "Creates a DB Task object with the Task class and typed status, priority, scheduled, and deadline properties. Never encode a task as Markdown TODO text."]
"listTags" ["List tags" "Returns a cursor-paginated list of tag entities in the graph."]
"createTag" ["Create a tag" "Creates a Logseq tag with the supplied title."]
"getTag" ["Get a tag" "Returns one tag by UUID."]
"listTagObjects" ["List objects with a tag" "Returns a cursor-paginated flat list of blocks and pages tagged with the addressed tag. Results are not recursively expanded."]
"updateTag" ["Update a tag" "Renames one tag identified by UUID."]
"deleteTag" ["Delete a tag" "Deletes one tag through Logseq's page deletion rules."]
"listProperties" ["List property definitions" "Returns a cursor-paginated list of property definitions in the graph."]
"createProperty" ["Create a property definition" "Creates a typed property definition in a DB graph with its name, value type, and cardinality. Use the returned UUID or ident to set values; never use legacy file-graph key:: value syntax."]
"getProperty" ["Get a property definition" "Returns one property definition by UUID."]
"updateProperty" ["Update a property definition" "Updates the name, value type, or cardinality of one typed property definition in a DB graph. Never use legacy file-graph key:: value syntax."]
"deleteProperty" ["Delete a property definition" "Deletes one property definition through Logseq's property deletion rules."]
"listAssets" ["List assets" "Returns a cursor-paginated list of asset blocks with optional creation and update time filters."]
"createAsset" ["Upload an asset" "Streams a file of at most 100MB to the daemon's local asset storage and creates its Asset-class block. Requires a client-computed SHA-256 checksum."]
"getAsset" ["Get an asset" "Returns asset metadata and a server-relative URL for the asset. Resolve the URL against the daemon's own base URL; it requires the same bearer token as every other route (or a ?token= query fallback for direct navigation)."]
"searchGraph" ["Search graph resources" "Searches blocks, tags, properties, and assets by title and returns cursor-paginated results."]})
(defn- operation-routes []
(->> operations
(group-by :path)
(mapv (fn [[path path-operations]]
[path {:methods (into {} (map (juxt :method identity)) path-operations)}]))))
(def ^:private router
(r/router (operation-routes)))
(defn match [method path]
(when-let [route (r/match-by-path router path)]
(when-let [operation (get-in route [:data :methods method])]
(merge operation (select-keys route [:path-params])))))
(defn- openapi-path [path]
(string/replace path #":([^/]+)" "{$1}"))
(defn- path-parameters [path]
(mapv (fn [[_ parameter]]
(cond-> {:name parameter :in "path" :required true :schema {:type "string"}}
(= "property-id" parameter)
(assoc :description "Property UUID, qualified ident, or exact title.")))
(re-seq #":([^/]+)" path)))
(def ^:private pagination-parameters
[{:name "limit" :in "query" :schema {:type "integer" :minimum 1 :maximum 200 :default 50}}
{:name "cursor" :in "query" :schema {:type "string"}}])
(def ^:private time-filter-parameters
[{:name "created-after" :in "query"
:description "Return entities created strictly after this Unix epoch timestamp in milliseconds."
:schema {:type "integer" :minimum 0}}
{:name "updated-after" :in "query"
:description "Return entities updated strictly after this Unix epoch timestamp in milliseconds."
:schema {:type "integer" :minimum 0}}])
(def ^:private asset-upload-parameters
[{:name "file-name" :in "query" :required true :schema {:type "string"}}
{:name "size" :in "query" :required true
:description "Exact decoded file size in bytes. Uploads larger than 100MB are rejected."
:schema {:type "integer" :minimum 0 :maximum 104857600}}
{:name "title" :in "query" :schema {:type "string"}}
{:name "page-id" :in "query"
:description "Destination page UUID. Omit to append to today's journal."
:schema {:type "string"}}
{:name "checksum" :in "query" :required true
:description "Lowercase or uppercase SHA-256 hex digest of the file."
:schema {:type "string" :pattern "^[0-9a-fA-F]{64}$"}}
{:name "encoding" :in "query"
:description "Use base64 when the caller cannot transfer raw binary. The server decodes it as a stream before writing to disk; size and checksum describe the decoded file."
:schema {:type "string" :enum ["base64"]}}])
(defn- request-schema [operation-id]
(case operation-id
"createPage" {:required ["title"] :properties {:title {:type "string"}}}
"updatePage" {:required ["title"] :properties {:title {:type "string"}}}
"updateBlock" {:required ["title"] :properties {:title {:type "string"}}}
"moveBlocks" {:required ["block-ids" "target-id" "position"]
:properties {:block-ids {:type "array" :minItems 1 :uniqueItems true
:items {:type "string"}}
:target-id {:type "string"}
:position {:enum ["before" "after" "first-child" "last-child"]}}}
"insertBlockChildren" {:required ["position" "blocks"]
:properties {:position {:enum ["append" "prepend"]}
:blocks {:type "array" :items {:$ref "#/components/schemas/BlockTree"}}}}
"insertBlockTree" {:required ["target-id" "blocks"]
:properties {:target-id {:type "string"}
:position {:enum ["append" "prepend"] :default "append"}
:blocks {:type "array" :items {:$ref "#/components/schemas/BlockTree"}}}}
"setBlockProperty" {:required ["value"]
:properties {:value {:$ref "#/components/schemas/PropertyValue"}}}
"batchSetBlockProperty" {:required ["entries"]
:properties {:isResetExistingValues
{:type "boolean" :default false
:description "For cardinality-many values, replace existing values when true; append when false or omitted."}
:entries {:type "array"
:items {:type "object" :required ["block-id" "property-id" "value"]
:properties {:block-id {:type "string"}
:property-id {:type "string"}
:value {:$ref "#/components/schemas/PropertyValue"}}}}}}
"batchDeleteBlockProperty" {:required ["entries"]
:properties {:entries {:type "array"
:items {:type "object" :required ["block-id" "property-id"]
:properties {:block-id {:type "string"}
:property-id {:type "string"}}}}}}
"captureToToday" {:required ["blocks"]
:properties {:blocks {:type "array" :items {:$ref "#/components/schemas/BlockTree"}}}}
"createTask" {:required ["title"]
:properties {:title {:type "string"}
:page-id {:type "string"
:description "Destination page UUID. Omit to append to today's journal."}
:status {:$ref "#/components/schemas/TaskStatusSelector"}
:priority {:$ref "#/components/schemas/TaskPrioritySelector"}
:scheduled {:type "integer" :description "Scheduled date as YYYYMMDD."}
:deadline {:type "integer" :description "Deadline date as YYYYMMDD."}}}
"createTag" {:required ["title"] :properties {:title {:type "string"}}}
"updateTag" {:required ["title"] :properties {:title {:type "string"}}}
"createProperty" {:required ["title"]
:properties {:title {:type "string"} :type {:$ref "#/components/schemas/PropertyType"}
:cardinality {:enum ["db.cardinality/one" "db.cardinality/many"]}}}
"updateProperty" {:properties {:title {:type "string"} :type {:$ref "#/components/schemas/PropertyType"}
:cardinality {:enum ["db.cardinality/one" "db.cardinality/many"]}}}
nil))
(defn- operation-parameters [{:keys [operation-id path]}]
(cond-> (path-parameters path)
(contains? #{"listPages" "listPageBlocks" "listPageReferences" "listTasks" "listTags"
"listTagObjects" "listProperties" "listAssets" "searchGraph"} operation-id)
(into pagination-parameters)
(contains? #{"listPages" "listTasks" "listTags" "listTagObjects" "listProperties" "listAssets" "searchGraph"}
operation-id)
(into time-filter-parameters)
(= "createAsset" operation-id)
(into asset-upload-parameters)
(= "searchGraph" operation-id)
(into [{:name "q" :in "query" :required true :schema {:type "string"}}
{:name "types" :in "query" :schema {:type "string"}}])
(= "listTasks" operation-id)
(into [{:name "status" :in "query" :schema {:$ref "#/components/schemas/TaskStatusSelector"}}
{:name "priority" :in "query" :schema {:$ref "#/components/schemas/TaskPrioritySelector"}}
{:name "content" :in "query" :schema {:type "string"}
:description "Case-insensitive task title filter."}])))
(defn- request-body [operation-id]
(if (= "createAsset" operation-id)
{:required true
:content {"application/octet-stream"
{:schema {:type "string" :format "binary"}}}}
(when-let [schema (request-schema operation-id)]
{:required true
:content {"application/json" {:schema (assoc schema :type "object")}}})))
(defn- list-response [response-key schema description]
{:description description
:content {"application/json"
{:schema {:type "object"
:required [(name response-key)]
:properties {response-key {:type "array"
:items {:$ref (str "#/components/schemas/" schema)}}
:next-cursor {:type "string"}}}}}})
(defn- entity-response [schema description]
{:description description
:content {"application/json" {:schema {:$ref (str "#/components/schemas/" schema)}}}})
(defn- operation-responses [operation-id]
(merge {"200" (case operation-id
"listPages" (list-response :pages "PageResponse" "A cursor page of pages")
"listPageBlocks"
(list-response :blocks "BlockResponse" "A cursor page of top-level block trees")
"listPageReferences" (list-response :references "BlockResponse" "A cursor page of page references")
"listTasks"
(list-response :tasks "TaskResponse" "A cursor page of DB Task objects")
"listTags" (list-response :tags "TagResponse" "A cursor page of tags")
"listTagObjects" (list-response :objects "BlockResponse" "A cursor page of tagged objects")
"listProperties" (list-response :properties "PropertyResponse" "A cursor page of property definitions")
"listAssets"
(list-response :assets "AssetResponse" "A cursor page of asset blocks")
"searchGraph" (list-response :results "SearchResultResponse" "A cursor page of search results")
"getPage" (entity-response "PageResponse" "A page")
"updatePage" (entity-response "PageResponse" "The updated page")
"getBlock" (entity-response "BlockResponse" "A block")
"updateBlock" (entity-response "BlockResponse" "The updated block")
"getTag" (entity-response "TagResponse" "A tag")
"updateTag" (entity-response "TagResponse" "The updated tag")
"getProperty"
(entity-response "PropertyResponse" "A property definition and its user-extensible choices")
"updateProperty" (entity-response "PropertyResponse" "The updated property definition")
"getAsset" (entity-response "AssetResponse" "Asset metadata and a server-relative URL")
{:description "Success"})
"400" {:description "Invalid request"}
"401" {:description "Missing or invalid bearer token"}
"404" {:description "Not found"}}
(case operation-id
"createPage" {"201" (entity-response "PageResponse" "The created page")}
"insertBlockChildren" {"201" {:description "The inserted block trees"}}
"insertBlockTree" {"201" {:description "The inserted block trees"}}
"createTag" {"201" (entity-response "TagResponse" "The created tag")}
"createProperty" {"201" (entity-response "PropertyResponse" "The created property definition")}
"createTask" {"201" (entity-response "TaskResponse" "The created DB Task object")}
"createAsset" {"201" (entity-response "AssetResponse" "The created asset block")}
{})))
(defn openapi-document
[{:keys [repo]}]
{:openapi "3.1.0"
:info {:title "Logseq Daemon Semantic API" :version "1.0.0"
:description (str "Operates on the graph bound to this daemon instance"
(when repo (str " (" repo ")")) ".")}
:servers [{:url "/"}]
:components
{:schemas (ordered-map
:BlockTree {:type "object" :required ["title"]
:properties {:title {:type "string"}
:children {:type "array" :items {:$ref "#/components/schemas/BlockTree"}}}}
:BlockResponse {:type "object" :required ["uuid" "kind" "title"]
:properties {:uuid {:type "string"}
:kind {:type "string"}
:title {:type "string"}
:order {:type "string"}
:parent-id {:type "string"}
:page-id {:type "string"}
:children {:type "array"
:items {:$ref "#/components/schemas/BlockResponse"}}}}
:PageResponse {:allOf [{:$ref "#/components/schemas/BlockResponse"}]}
:TagResponse {:type "object" :required ["uuid" "title"]
:properties {:uuid {:type "string"}
:title {:type "string"}
:ident {:type "string"}}}
:SearchResultResponse {:type "object" :required ["uuid" "title" "resource"]
:properties {:uuid {:type "string"}
:title {:type "string"}
:resource {:enum ["blocks" "tags" "properties" "assets"]}}}
:EntitySelector {:type "string"
:description "An existing entity UUID, qualified ident, or exact title."}
:TaskStatusSelector
{:description "A built-in status alias or any user-extensible Status property choice selected by UUID, ident, or exact title."
:anyOf [{:type "string" :enum ["todo" "doing" "in-review" "done" "canceled" "backlog"]}
{:$ref "#/components/schemas/EntitySelector"}]}
:TaskPrioritySelector
{:description "A built-in priority alias or any user-extensible Priority property choice selected by UUID, ident, or exact title."
:anyOf [{:type "string" :enum ["low" "medium" "high" "urgent"]}
{:$ref "#/components/schemas/EntitySelector"}]}
:PropertyChoice {:type "object" :required ["uuid" "title"]
:properties {:uuid {:type "string"}
:ident {:type "string"}
:title {:type "string"}
:value {}}}
:PropertyResponse {:type "object" :required ["uuid" "title" "type" "cardinality"]
:properties {:uuid {:type "string"}
:ident {:type "string"}
:title {:type "string"}
:type {:type "string"}
:cardinality {:type "string"}
:choices {:type "array"
:description "The current user-extensible choices for this property."
:items {:$ref "#/components/schemas/PropertyChoice"}}}}
:PropertyType {:type "string"
:enum ["default" "number" "date" "datetime" "checkbox" "url" "node" "asset"
"map" "json" "string"]}
:AssetResponse {:type "object" :required ["uuid" "title" "type" "size" "checksum"]
:properties {:uuid {:type "string"}
:title {:type "string"}
:type {:type "string"}
:size {:type "integer" :minimum 0 :maximum 104857600}
:checksum {:type "string" :pattern "^[0-9a-f]{64}$"}
:url {:type "string"}}}
:TaskResponse {:allOf [{:$ref "#/components/schemas/BlockResponse"}
{:type "object"
:required ["status"]
:properties {:status {:$ref "#/components/schemas/PropertyChoice"}
:priority {:$ref "#/components/schemas/PropertyChoice"}
:scheduled {:type "integer"}
:deadline {:type "integer"}}}]}
:PropertyValue
{:description "For ref properties, pass an existing entity by UUID, ident, or title. Status and Priority also accept case-insensitive built-in aliases such as TODO, DONE, and HIGH; user-defined choices use UUID, ident, or exact title. Class properties such as Tags accept a class UUID, ident, or title, or an array of those selectors. Scalar properties accept their JSON scalar value."
:oneOf [{:type "string"} {:type "number"} {:type "boolean"}
{:type "array" :items {:oneOf [{:type "string"} {:type "number"}]}}]})
:securitySchemes
{:bearerAuth {:type "http" :scheme "bearer"
:description "Session token from POST /v1/auth/login."}}}
:security [{:bearerAuth []}]
:paths
(reduce (fn [paths {:keys [method path operation-id]}]
(let [[summary description] (get operation-docs operation-id)]
(assoc-in paths [(openapi-path path) (keyword (.toLowerCase method))]
(cond-> {:operationId operation-id
:summary summary
:description description
:parameters (operation-parameters {:operation-id operation-id :path path})
:responses (operation-responses operation-id)}
(request-body operation-id)
(assoc :requestBody (request-body operation-id))))))
(ordered-map)
operations)})