diff --git a/src/main/frontend/worker/db_core.cljs b/src/main/frontend/worker/db_core.cljs index c37bfa8..9f02897 100644 --- a/src/main/frontend/worker/db_core.cljs +++ b/src/main/frontend/worker/db_core.cljs @@ -24,6 +24,7 @@ [frontend.worker.handler.query] [frontend.worker.handler.render-resource.engine] [frontend.worker.handler.search :as search-handler] + [frontend.worker.handler.semantic] [frontend.worker.handler.transaction :as transaction-handler] [frontend.worker.handler.undo-redo] [frontend.worker.handler.view] diff --git a/src/main/frontend/worker/db_worker_node.cljs b/src/main/frontend/worker/db_worker_node.cljs index 18fc1bb..936860f 100644 --- a/src/main/frontend/worker/db_worker_node.cljs +++ b/src/main/frontend/worker/db_worker_node.cljs @@ -9,6 +9,7 @@ [frontend.worker.db-worker-node-lock :as db-lock] [frontend.worker.db-worker-node-ws :as db-ws] [frontend.worker.platform.node :as platform-node] + [frontend.worker.semantic-routes :as semantic-routes] [frontend.worker.state :as worker-state] [lambdaisland.glogi :as log] [logseq.common.graph-dir :as graph-dir] @@ -432,6 +433,52 @@ [^js buf] (str "\"" (-> (crypto/createHash "sha1") (.update buf) (.digest "hex")) "\"")) +(defn- query-params-map + "Ring/bidi-style :page-id params come from semantic-routes' reitit router + already keyword-keyed; the query string doesn't, so build it the same way + by hand from URLSearchParams -- there's no js->clj for it." + [^js parsed-url] + (into {} + (map (fn [pair] [(keyword (aget pair 0)) (aget pair 1)])) + (js/Array.from (.entries (.-searchParams parsed-url))))) + +(defn- clj (js/JSON.parse body) :keywordize-keys true))))) + +(defn- semantic-request-handler + "/v1/semantic/* -- unlike /v1/invoke (an internal RPC transport that hands + transit-encoded results straight back to Logseq's own clients, which + already know how to decode them), this is the public HTTP API documented + by semantic-routes/openapi-document and consumed by arbitrary HTTP/MCP + clients, so results are decoded server-side into plain JSON matching the + OpenAPI schemas -- the same shape the /v1/assets/:repo/:name GET route + already produces for the one other route with external HTTP consumers." + [^js req ^js res bound-repo proxy parsed-url request-path method] + (if-let [{:keys [handler success-status path-params]} (semantic-routes/match method request-path)] + (let [query-params (query-params-map parsed-url) + method-kw :thread-api/semantic-request + method-str (normalize-method-str method-kw)] + (-> (p/let [body (if (= handler :semantic/assets-create) + ( (p/let [body ( {:uuid (uuid-string (:block/uuid block)) + :kind (block-kind block) + :title (:block/title block) + :order (:block/order block)} + (:block/parent block) (assoc :parent-id (uuid-string (:block/uuid (:block/parent block)))) + (:block/page block) (assoc :page-id (uuid-string (:block/uuid (:block/page block)))) + (seq (:block/children block)) (assoc :children (mapv block-response (:block/children block))))) + +(defn- tag-response [entity] + {:uuid (uuid-string (:block/uuid entity)) + :ident (entity-ident entity) + :title (:block/title entity)}) + +(defn- property-choice-response [entity] + {:uuid (uuid-string (:block/uuid entity)) + :ident (entity-ident entity) + :title (:block/title entity) + :value (:logseq.property/value entity)}) + +(defn- property-response [entity] + (cond-> {:uuid (uuid-string (:block/uuid entity)) + :ident (entity-ident entity) + :title (:block/title entity) + :type (some-> (:logseq.property/type entity) name) + :cardinality (some-> (:db/cardinality entity) name)} + (seq (:block/_closed-value-property entity)) + (assoc :choices (mapv property-choice-response (:block/_closed-value-property entity))))) + +(defn- asset-response [entity] + {:uuid (uuid-string (:block/uuid entity)) + :title (:block/title entity) + :type (:logseq.property.asset/type entity) + :size (:logseq.property.asset/size entity) + :checksum (:logseq.property.asset/checksum entity)}) + +(defn- asset-file-name [entity] + (str (:block/uuid entity) "." (:logseq.property.asset/type entity))) + +(defn- entities-by-avet [db attribute & [value]] + (->> (if (some? value) + (d/datoms db :avet attribute value) + (d/datoms db :avet attribute)) + (map #(d/entity db (:e %))) + (remove entity-util/hidden?))) + +(defn- parse-limit [{:keys [limit]}] + (let [parsed (when limit (js/parseInt limit 10))] + (cond + (nil? limit) default-limit + (or (js/isNaN parsed) (not (pos? parsed))) nil + :else (min parsed max-limit)))) + +(def ^:private text-encoder (js/TextEncoder.)) +(def ^:private text-decoder (js/TextDecoder.)) + +(defn- encode-cursor [key] + (let [payload (.encode text-encoder (js/JSON.stringify (clj->js key))) + binary (.join (.map (js/Array.from payload) #(js/String.fromCharCode %)) "")] + (js/btoa binary))) + +(defn- decode-cursor [value] + (when value + (try + (let [binary (js/atob value) + payload (js/Uint8Array.from (js/Array.from binary #(.charCodeAt % 0))) + decoded (js->clj (js/JSON.parse (.decode text-decoder payload)))] + (when (and (vector? decoded) (= 2 (count decoded)) (every? string? decoded)) decoded)) + (catch :default _ nil)))) + +(defn- sort-key [item] + [(or (:order item) (:block/order item) + (string/lower-case (or (:title item) (:block/title item) ""))) + (str (or (:uuid item) (:block/uuid item) ""))]) + +(defn- paginate [items limit cursor] + (let [sorted (sort-by sort-key items) + remaining (if cursor (drop-while #(not (pos? (compare (sort-key %) cursor))) sorted) sorted) + selected (vec (take (inc limit) remaining)) + more? (> (count selected) limit) + page (vec (take limit selected))] + (cond-> {:items page} + more? (assoc :next-cursor (encode-cursor (sort-key (last page))))))) + +(defn- paginate-bounded [items limit cursor] + (let [selected (reduce (fn [selected item] + (if (or (nil? cursor) + (pos? (compare (sort-key item) cursor))) + (->> (conj selected item) + (sort-by sort-key) + (take (inc limit)) + vec) + selected)) + [] + items) + more? (> (count selected) limit) + page (vec (take limit selected))] + (cond-> {:items page} + more? (assoc :next-cursor (encode-cursor (sort-key (last page))))))) + +(defn- pagination-options [query-params] + {:limit (parse-limit query-params) + :raw-cursor (:cursor query-params) + :cursor (decode-cursor (:cursor query-params))}) + +(defn- parse-timestamp [raw] + (cond + (nil? raw) nil + (not (re-matches #"\d+" raw)) ::invalid + :else (js/parseInt raw 10))) + +(defn- time-filter-options [query-params] + (let [created-after (parse-timestamp (:created-after query-params)) + updated-after (parse-timestamp (:updated-after query-params))] + {:created-after created-after + :updated-after updated-after + :invalid? (or (= ::invalid created-after) (= ::invalid updated-after))})) + +(defn- after-time-filters? [entity {:keys [created-after updated-after]}] + (and (or (nil? created-after) + (and (number? (:block/created-at entity)) + (> (:block/created-at entity) created-after))) + (or (nil? updated-after) + (and (number? (:block/updated-at entity)) + (> (:block/updated-at entity) updated-after))))) + +(defn- paginated-response + ([query-params response-key items] + (paginated-response query-params response-key items identity)) + ([query-params response-key items response-fn] + (paginated-response query-params response-key items response-fn paginate)) + ([query-params response-key items response-fn paginate-fn] + (let [{:keys [limit raw-cursor cursor]} (pagination-options query-params) + time-filters (time-filter-options query-params)] + (cond + (nil? limit) (bad-request "invalid limit") + (and raw-cursor (nil? cursor)) (bad-request "invalid cursor") + (:invalid? time-filters) (bad-request "invalid time filter") + :else (let [items (filter #(after-time-filters? % time-filters) items) + {:keys [items next-cursor]} (paginate-fn items limit cursor)] + (cond-> {response-key (mapv response-fn items)} + next-cursor (assoc :next-cursor next-cursor))))))) + +(defn- indexed-reference-response + ([db query-params response-key attribute target-id] + (indexed-reference-response db query-params response-key attribute target-id {})) + ([db query-params response-key attribute target-id {:keys [entity-filter response-fn] + :or {entity-filter (constantly true) + response-fn block-response}}] + (let [{:keys [limit raw-cursor cursor]} (pagination-options query-params) + time-filters (time-filter-options query-params) + cursor-e (when (= "entity-id" (first cursor)) + (js/parseInt (second cursor) 10))] + (cond + (nil? limit) (bad-request "invalid limit") + (and raw-cursor (or (nil? cursor) (js/isNaN cursor-e))) (bad-request "invalid cursor") + (:invalid? time-filters) (bad-request "invalid time filter") + :else + (let [datoms (cond->> (d/datoms db :avet attribute target-id) + cursor-e (drop-while #(<= (:e %) cursor-e)) + true (remove #(entity-util/hidden? (d/entity db (:e %)))) + true (filter #(after-time-filters? (d/entity db (:e %)) time-filters)) + true (filter #(entity-filter (d/entity db (:e %))))) + selected (vec (take (inc limit) datoms)) + more? (> (count selected) limit) + page (take limit selected)] + (cond-> {response-key (mapv #(response-fn (d/entity db (:e %))) page)} + more? (assoc :next-cursor + (encode-cursor ["entity-id" (str (:e (last page)))])))))))) + +(defn- find-entity [db id] + (when-let [parsed (try (uuid id) (catch :default _ nil))] + (d/entity db [:block/uuid parsed]))) + +(defn- find-visible-entity [db id] + (when-let [entity (find-entity db id)] + (when-not (entity-util/hidden? entity) entity))) + +(def ^:private page-block-pull-keys + [:db/id :block/uuid :block/title :block/order + :logseq.property/hide? :logseq.property/deleted-at + {:block/parent [:db/id :block/uuid]} + {:block/page [:db/id :block/uuid]}]) + +(defn- page-root-blocks [db page] + (let [page-id (:db/id page)] + (->> (d/datoms db :avet :block/page page-id) + (filter #(seq (d/datoms db :eavt (:e %) :block/parent page-id))) + (map #(:e %)) + (map #(d/pull db [:db/id :block/uuid :block/title :block/order + :block/created-at :block/updated-at + :logseq.property/hide? :logseq.property/deleted-at + {:block/parent [:db/id :block/uuid]} + {:block/page [:db/id :block/uuid]}] + %)) + (remove entity-util/hidden?)))) + +(defn- page-block-tree [db root] + (let [blocks (->> (ldb/get-block-and-children db (:block/uuid root)) + (remove #(entity-util/hidden? (d/entity db (:db/id %)))))] + (some->> blocks + (map #(d/pull db page-block-pull-keys (:db/id %))) + (otree/non-consecutive-blocks->vec-tree) + first + block-response))) + +(defn- paginated-page-blocks-response [db page query-params] + (let [{:keys [limit raw-cursor cursor]} (pagination-options query-params) + time-filters (time-filter-options query-params)] + (cond + (nil? limit) (bad-request "invalid limit") + (and raw-cursor (nil? cursor)) (bad-request "invalid cursor") + (:invalid? time-filters) (bad-request "invalid time filter") + :else + (let [{roots :items next-cursor :next-cursor} + (paginate (filter #(after-time-filters? % time-filters) + (page-root-blocks db page)) + limit + cursor)] + (cond-> {:blocks (mapv #(page-block-tree db %) roots)} + next-cursor (assoc :next-cursor next-cursor)))))) + +(defn- list-pages [db] + (->> [:logseq.class/Page :logseq.class/Journal] + (keep #(d/entid db %)) + (mapcat #(entities-by-avet db :block/tags %)))) + +(defn- page-ref-values [title] + (when (string? title) + (->> (re-seq page-ref/page-ref-re title) + (map (comp string/trim second)) + (remove string/blank?) + distinct))) + +(defn- integer-string? [value] + (boolean (re-matches #"-?\d+" value))) + +(defn- resolve-page-ref! [conn value] + (cond + (common-util/uuid-string? value) + (when-let [entity (d/entity @conn [:block/uuid (uuid value)])] + {:block/uuid (:block/uuid entity) + :block/title value}) + + (integer-string? value) + (when-let [entity (d/entity @conn (js/parseInt value 10))] + {:block/uuid (:block/uuid entity) + :block/title value}) + + :else + (let [page (or (ldb/get-page @conn value) + (let [[_ page-id] (outliner-page/create! conn value {})] + (d/entity @conn [:block/uuid page-id])))] + {:block/uuid (:block/uuid page) + :block/title (:block/title page)}))) + +(defn- prepare-block-title! [conn title] + (let [refs (->> (page-ref-values title) + (keep #(resolve-page-ref! conn %)) + (reduce (fn [result ref] + (assoc result (:block/uuid ref) ref)) {}) + vals + vec)] + {:block/title (db-content/title-ref->id-ref title refs :replace-tag? false) + :block/refs (mapv #(vector :block/uuid (:block/uuid %)) refs)})) + +(defn- tree-block [conn node] + (assoc (prepare-block-title! conn (:title node)) :block/uuid (random-uuid))) + +(defn- insert-tree! [conn target nodes position] + (let [nodes (vec nodes) + blocks (mapv #(tree-block conn %) nodes)] + (when (seq blocks) + (outliner-core/insert-blocks! conn blocks target + {:sibling? false :top? (= position "prepend") + :bottom? (not= position "prepend") :keep-uuid? true}) + (mapv (fn [node block] + (let [children (when (seq (:children node)) + (insert-tree! conn (d/entity @conn [:block/uuid (:block/uuid block)]) + (:children node) "append"))] + (cond-> {:uuid (str (:block/uuid block)) :title (:title node)} + (seq children) (assoc :children children)))) + nodes blocks)))) + +(defn- today-journal-day [] + (let [now (js/Date.)] + (+ (* (.getFullYear now) 10000) (* (inc (.getMonth now)) 100) (.getDate now)))) + +(defn- ensure-today-page! [conn] + (let [day (today-journal-day)] + (or (ldb/get-journal-page-by-day @conn day) + (let [formatter (:logseq.property.journal/title-format (d/entity @conn :logseq.class/Journal)) + title (date-time-util/int->journal-title day formatter) + [_ page-id] (outliner-page/create! conn title {:journal? true :today-journal? true})] + (d/entity @conn [:block/uuid page-id]))))) + +(defn- resolve-property-ident [db value] + (or (when-let [entity (find-entity db value)] (:db/ident entity)) + (when (string? value) + (let [ident (keyword value)] + (or (when (d/entity db ident) ident) + (some->> (d/datoms db :avet :block/title value) + (map #(d/entity db (:e %))) + (filter property?) + first + :db/ident)))))) + +(defn- resolve-property [db value] + (when-let [ident (resolve-property-ident db value)] + (let [property (d/entity db ident)] + (when-not (entity-util/hidden? property) + property)))) + +(def ^:private allowed-property-types + (into (set db-property-type/user-built-in-property-types) + db-property-type/user-allowed-internal-property-types)) + +(def ^:private allowed-cardinalities + #{:db.cardinality/one :db.cardinality/many}) + +(defn- valid-property-schema? [{:keys [type cardinality]}] + (and (or (nil? type) + (and (string? type) (contains? allowed-property-types (keyword type)))) + (or (nil? cardinality) + (and (string? cardinality) (contains? allowed-cardinalities (keyword cardinality)))))) + +(def ^:private task-status-idents + {"todo" :logseq.property/status.todo + "doing" :logseq.property/status.doing + "in-review" :logseq.property/status.in-review + "done" :logseq.property/status.done + "canceled" :logseq.property/status.canceled + "backlog" :logseq.property/status.backlog}) + +(def ^:private task-priority-idents + {"low" :logseq.property/priority.low + "medium" :logseq.property/priority.medium + "high" :logseq.property/priority.high + "urgent" :logseq.property/priority.urgent}) + +(def ^:private property-choice-aliases + {:logseq.property/status task-status-idents + :logseq.property/priority task-priority-idents}) + +(defn- resolve-ref-entity [db property-ident value] + (let [value (if (string? value) + (get-in property-choice-aliases + [property-ident (string/lower-case value)] value) + value) + entity (cond + (number? value) (d/entity db value) + (qualified-keyword? value) (d/entity db value) + (and (string? value) (common-util/uuid-string? value)) + (d/entity db [:block/uuid (uuid value)]) + (string? value) + (or (d/entity db (keyword value)) + (some->> (d/datoms db :avet :block/title value) + (map #(d/entity db (:e %))) + (filter #(if (= property-ident :block/tags) + (entity-util/class? %) + true)) + first)) + :else nil)] + (when (and entity + (or (not= property-ident :block/tags) (entity-util/class? entity))) + (:db/id entity)))) + +(defn- prepare-property-value [db property-ident value] + (let [property (d/entity db property-ident)] + (if-not (= :db.type/ref (:db/valueType property)) + {:value value} + (let [many-input? (or (sequential? value) (set? value)) + values (if many-input? value [value]) + resolved (mapv #(resolve-ref-entity db property-ident %) values)] + (if (every? some? resolved) + {:value (if many-input? resolved (first resolved))} + {:error (if (= property-ident :block/tags) + "property value must resolve to an existing class by UUID, ident, or title" + "property value must resolve to an existing entity by UUID, ident, or title")}))))) + +(defn- set-prepared-property! [conn block-id property-ident value reset-existing-values?] + (if (coll? value) + (do + (when reset-existing-values? + (outliner-property/remove-block-property! conn block-id property-ident)) + (doseq [item value] + (outliner-property/batch-set-property! conn [block-id] property-ident item {:entity-id? true}))) + (outliner-property/set-block-property! conn block-id property-ident value))) + +(defn- task-response [task] + (cond-> (block-response task) + (:logseq.property/status task) + (assoc :status (property-choice-response (:logseq.property/status task))) + (:logseq.property/priority task) + (assoc :priority (property-choice-response (:logseq.property/priority task))) + (:logseq.property/scheduled task) (assoc :scheduled (:logseq.property/scheduled task)) + (:logseq.property/deadline task) (assoc :deadline (:logseq.property/deadline task)))) + +(defn- resolve-property-choice [db property-ident aliases selector] + (let [selector (get aliases selector selector) + choice-id (resolve-ref-entity db property-ident selector) + choice (when choice-id (d/entity db choice-id)) + property-id (d/entid db property-ident)] + (when (and choice-id + (some #(= property-id (:v %)) + (d/datoms db :eavt choice-id :block/closed-value-property))) + choice))) + +(defn- search-result-response [entity] + (let [kind (block-kind entity) + type (case kind "tag" "tags" "property" "properties" "asset" "assets" "blocks") + response (case kind + "tag" (tag-response entity) + "property" (property-response entity) + "asset" (asset-response entity) + (block-response entity))] + (assoc response :resource type))) + +(defn- search-results [db query types] + (let [needle (string/lower-case query) + enabled (if (seq types) (set (string/split types #",")) #{"blocks" "tags" "properties" "assets"}) + title-matches (keep (fn [datom] + (try + (when (string/includes? (string/lower-case (:v datom)) needle) + (:e datom)) + (catch :default _ + nil))) + (d/datoms db :avet :block/title))] + (->> title-matches + (map #(d/entity db %)) + (remove entity-util/hidden?) + (filter (fn [entity] + (let [kind (block-kind entity) + type (case kind "tag" "tags" "property" "properties" "asset" "assets" "blocks")] + (and (contains? enabled type) + (string/includes? (string/lower-case (or (:block/title entity) "")) needle)))))))) + +(defn- handle-pages [{:keys [conn db handler path-params query-params body]}] + (case handler + :semantic/pages-list + (paginated-response query-params :blocks (list-pages db) block-response) + + :semantic/pages-create + (if-not (seq (:title body)) + (bad-request "missing title") + (let [[title page-id] (outliner-page/create! conn (:title body) {})] + {:uuid (str page-id) :kind "page" :title title})) + + :semantic/pages-blocks + (if-let [page (find-visible-entity db (:page-id path-params))] + (if-not (page? page) + (bad-request "block is not a page") + (paginated-page-blocks-response db page query-params)) + (not-found)) + + :semantic/pages-references + (if-let [page (find-visible-entity db (:page-id path-params))] + (if (page? page) + (indexed-reference-response db query-params :references :block/refs (:db/id page)) + (not-found)) + (not-found)) + + :semantic/pages-get + (if-let [page (find-visible-entity db (:page-id path-params))] + (if (page? page) (block-response page) (not-found)) + (not-found)) + + :semantic/pages-update + (let [page (find-entity db (:page-id path-params))] + (if (or (not (page? page)) (not (seq (:title body)))) + (bad-request "invalid page-id or title") + (do + (outliner-core/save-block! conn {:block/uuid (:block/uuid page) :block/title (:title body)}) + (assoc (block-response page) :title (:title body))))) + + :semantic/pages-delete + (if-let [page (find-entity db (:page-id path-params))] + (if-not (page? page) + (bad-request "block is not a page") + (do (outliner-page/delete! conn (:block/uuid page)) nil)) + (not-found)))) + +(defn- handle-blocks [{:keys [conn db handler path-params body]}] + (case handler + + :semantic/blocks-get + (if-let [block (find-visible-entity db (:block-id path-params))] + (block-response block) + (not-found)) + + :semantic/blocks-update + (let [block (find-entity db (:block-id path-params))] + (if (or (nil? block) (not (seq (:title body)))) + (bad-request "invalid block-id or title") + (do + (outliner-core/save-block! conn + (assoc (prepare-block-title! conn (:title body)) + :block/uuid (:block/uuid block))) + {:uuid (str (:block/uuid block)) :kind (block-kind block) :title (:title body)}))) + + :semantic/blocks-delete + (if-let [block (find-entity db (:block-id path-params))] + (do + (if (page? block) + (outliner-page/delete! conn (:block/uuid block)) + (outliner-core/delete-blocks! conn [block] {})) + nil) + (not-found)) + + :semantic/blocks-move + (let [block-ids (:block-ids body) + blocks (mapv #(find-entity db %) block-ids) + target (find-entity db (:target-id body))] + (if (or (not (seq block-ids)) + (some nil? blocks) (nil? target) + (not= (count blocks) (count (distinct (map :db/id blocks)))) + (some #(= (:db/id target) (:db/id %)) blocks) + (not (contains? #{"before" "after" "first-child" "last-child"} (:position body)))) + (bad-request "invalid blocks, target, or position") + (let [position (:position body) + opts (case position + "before" {:sibling? true :top? true} + "after" {:sibling? true :bottom? true} + "first-child" {:sibling? false :top? true} + "last-child" {:sibling? false :bottom? true})] + (outliner-core/move-blocks! conn blocks target opts) + {:uuids (mapv #(str (:block/uuid %)) blocks) :moved true}))) + + :semantic/blocks-insert-children + (let [target (find-entity db (:block-id path-params))] + (if (or (nil? target) (not (contains? #{"append" "prepend"} (:position body))) + (not (seq (:blocks body)))) + (bad-request "invalid target, position, or blocks") + {:blocks (insert-tree! conn target (:blocks body) (:position body))})) + + :semantic/blocks-insert-tree + (let [target (find-entity db (:target-id body))] + (if (or (nil? target) (not (seq (:blocks body)))) + (bad-request "invalid target or blocks") + {:blocks (insert-tree! conn target (:blocks body) (or (:position body) "append"))})))) + +(defn- handle-block-properties [{:keys [conn db handler path-params body]}] + (case handler + + :semantic/blocks-set-property + (let [block (find-entity db (:block-id path-params)) + property-ident (resolve-property-ident db (:property-id path-params)) + prepared (when property-ident (prepare-property-value db property-ident (:value body)))] + (cond + (nil? block) (bad-request "block not found") + (nil? property-ident) (bad-request "property not found") + (not (contains? body :value)) (bad-request "missing property value") + (:error prepared) (bad-request (:error prepared)) + :else + (do (set-prepared-property! conn (:db/id block) property-ident (:value prepared) true) + {:updated true}))) + + :semantic/blocks-delete-property + (let [block (find-entity db (:block-id path-params)) + property-ident (resolve-property-ident db (:property-id path-params))] + (if (or (nil? block) (nil? property-ident)) + (bad-request "invalid block or property") + (do + (outliner-property/remove-block-property! conn (:db/id block) property-ident) + nil))) + + :semantic/blocks-batch-set-property + (let [entries (:entries body) + resolved (mapv (fn [{:keys [block-id property-id value]}] + (let [property-ident (resolve-property-ident db property-id)] + {:block (find-entity db block-id) + :property-ident property-ident + :prepared (when property-ident + (prepare-property-value db property-ident value))})) entries)] + (if (or (not (seq entries)) + (some #(or (nil? (:block %)) (nil? (:property-ident %)) + (:error (:prepared %))) resolved)) + (bad-request "invalid batch property entry") + (do (ldb/batch-transact-with-temp-conn! + conn {:outliner-op :batch-set-property} + (fn [temp-conn] + (doseq [{:keys [block property-ident prepared]} resolved] + (set-prepared-property! temp-conn (:db/id block) property-ident (:value prepared) + (true? (:isResetExistingValues body)))))) + {:updated (count resolved)}))) + + :semantic/blocks-batch-delete-property + (let [entries (:entries body) + resolved (mapv (fn [{:keys [block-id property-id]}] + {:block (find-entity db block-id) + :property-ident (resolve-property-ident db property-id)}) entries)] + (if (or (not (seq entries)) (some #(or (nil? (:block %)) (nil? (:property-ident %))) resolved)) + (bad-request "invalid batch property entry") + (do + (ldb/batch-transact-with-temp-conn! + conn {:outliner-op :batch-remove-property} + (fn [temp-conn] + (doseq [{:keys [block property-ident]} resolved] + (outliner-property/remove-block-property! temp-conn (:db/id block) property-ident)))) + {:deleted (count resolved)}))))) + +(defn- handle-capture-and-tags [{:keys [conn db handler path-params query-params body]}] + (case handler + + :semantic/capture + (if-not (seq (:blocks body)) + (bad-request "missing blocks") + (let [today (ensure-today-page! conn) + inserted (insert-tree! conn today (:blocks body) "append")] + {:page-id (str (:block/uuid today)) :blocks inserted})) + + :semantic/tags-list + (paginated-response query-params :tags (entities-by-avet db :block/tags :logseq.class/Tag) tag-response) + + :semantic/tags-create + (if-not (seq (:title body)) + (bad-request "missing title") + (let [[title tag-id] (outliner-page/create! conn (:title body) {:class? true})] + {:uuid (str tag-id) :title title})) + + :semantic/tags-get + (if-let [tag (find-visible-entity db (:tag-id path-params))] + (if (tag? tag) (tag-response tag) (not-found)) + (not-found)) + + :semantic/tags-objects + (if-let [tag (find-visible-entity db (:tag-id path-params))] + (if (tag? tag) + (indexed-reference-response db query-params :objects :block/tags (:db/id tag)) + (not-found)) + (not-found)) + + :semantic/tags-update + (let [tag (find-entity db (:tag-id path-params))] + (if (or (not (tag? tag)) (not (seq (:title body)))) + (bad-request "invalid tag-id or title") + (do + (outliner-core/save-block! conn {:block/uuid (:block/uuid tag) :block/title (:title body)}) + (assoc (tag-response tag) :title (:title body))))) + + :semantic/tags-delete + (if-let [tag (find-entity db (:tag-id path-params))] + (if-not (tag? tag) + (not-found) + (do (outliner-page/delete! conn (:block/uuid tag)) nil)) + (not-found)))) + +(defn- handle-tasks [{:keys [conn db handler query-params body]}] + (case handler + :semantic/tasks-list + (let [status (:status query-params) + priority (:priority query-params) + content (some-> (:content query-params) string/lower-case) + status-choice (when status (resolve-property-choice db :logseq.property/status + task-status-idents status)) + priority-choice (when priority (resolve-property-choice db :logseq.property/priority + task-priority-idents priority))] + (cond + (and status (nil? status-choice)) (bad-request "invalid task status") + (and priority (nil? priority-choice)) (bad-request "invalid task priority") + :else + (indexed-reference-response + db query-params :tasks :block/tags :logseq.class/Task + {:entity-filter + (fn [entity] + (and (or (nil? status-choice) + (= (:db/id status-choice) (:db/id (:logseq.property/status entity)))) + (or (nil? priority-choice) + (= (:db/id priority-choice) (:db/id (:logseq.property/priority entity)))) + (or (nil? content) + (string/includes? (string/lower-case (or (:block/title entity) "")) content)))) + :response-fn task-response}))) + + :semantic/tasks-create + (let [status (or (:status body) "todo") + priority (:priority body) + status-choice (resolve-property-choice db :logseq.property/status task-status-idents status) + priority-choice (when priority + (resolve-property-choice db :logseq.property/priority + task-priority-idents priority)) + target (when-let [page-id (:page-id body)] + (find-entity db page-id))] + (cond + (not (seq (:title body))) (bad-request "missing task title") + (nil? status-choice) (bad-request "invalid task status") + (and priority (nil? priority-choice)) (bad-request "invalid task priority") + (and (:page-id body) (not (page? target))) (bad-request "invalid task page-id") + :else + (let [task-id (atom nil)] + (ldb/batch-transact-with-temp-conn! + conn {:outliner-op :create-task} + (fn [temp-conn] + (let [target (if target + (d/entity @temp-conn [:block/uuid (:block/uuid target)]) + (ensure-today-page! temp-conn)) + inserted (first (insert-tree! temp-conn target [{:title (:title body)}] "append")) + task-uuid (uuid (:uuid inserted)) + task (d/entity @temp-conn [:block/uuid task-uuid])] + (reset! task-id task-uuid) + (outliner-property/set-block-property! temp-conn (:db/id task) :block/tags + (:db/id (d/entity @temp-conn :logseq.class/Task))) + (outliner-property/set-block-property! temp-conn (:db/id task) :logseq.property/status + (:db/id status-choice)) + (when priority-choice + (outliner-property/set-block-property! temp-conn (:db/id task) :logseq.property/priority + (:db/id priority-choice))) + (doseq [property-ident [:logseq.property/scheduled :logseq.property/deadline] + :let [value (get body (keyword (name property-ident)))] + :when (some? value)] + (outliner-property/set-block-property! temp-conn (:db/id task) property-ident value))))) + (task-response (d/entity @conn [:block/uuid @task-id]))))))) + +(defn- handle-assets + [{:keys [repo conn db handler path-params query-params body]}] + (case handler + :semantic/assets-list + (paginated-response query-params :assets + (entities-by-avet db :logseq.property.asset/type) + asset-response) + + :semantic/assets-create + (let [{:keys [file-name size checksum encoding page-id title]} query-params + size (when (and size (re-matches #"\d+" (str size))) (js/parseInt size 10)) + checksum (some-> checksum string/lower-case) + asset-type (when (string? file-name) (db-asset/asset-path->type file-name)) + target (when page-id (find-entity db page-id))] + (cond + (or (not (seq file-name)) (not (seq asset-type))) (bad-request "invalid asset file-name") + (nil? size) (bad-request "invalid asset size") + (> size max-asset-size) (api-error! "asset too large" 413) + (not (and (string? checksum) (re-matches #"[0-9a-f]{64}" checksum))) + (bad-request "invalid asset checksum") + (and encoding (not= "base64" encoding)) (bad-request "invalid asset encoding") + (and page-id (not (page? target))) (bad-request "invalid asset page-id") + (seq (d/datoms db :avet :logseq.property.asset/checksum checksum)) + (api-error! "asset checksum already exists" 409) + :else + (let [asset-id (random-uuid) + file-name* (str asset-id "." asset-type) + title (or title (db-asset/asset-name->title file-name)) + payload (if (= "base64" encoding) + (js/Buffer.from (str body) "base64") + body)] + (platform/asset-write-bytes! (platform/current) repo file-name* payload) + (let [target (or target (ensure-today-page! conn)) + block {:block/uuid asset-id + :block/title title + :block/tags #{:logseq.class/Asset} + :logseq.property.asset/type asset-type + :logseq.property.asset/size size + :logseq.property.asset/checksum checksum}] + (outliner-core/insert-blocks! conn [block] target + {:sibling? false :bottom? true :keep-uuid? true}) + (asset-response (d/entity @conn [:block/uuid asset-id])))))) + + :semantic/assets-get + (if-let [asset (find-visible-entity db (:asset-block-id path-params))] + (if-not (asset? asset) + (bad-request "block is not an asset") + (assoc (asset-response asset) + :url (str "/v1/assets/" (js/encodeURIComponent repo) "/" + (js/encodeURIComponent (asset-file-name asset))))) + (not-found)))) + +(defn- handle-properties-and-search + [{:keys [conn db handler path-params query-params body]}] + (case handler + + :semantic/properties-list + (paginated-response query-params :properties (->> (db-db/get-all-properties db) + (remove entity-util/hidden?)) + property-response) + + :semantic/properties-create + (if (or (not (seq (:title body))) (not (valid-property-schema? body))) + (bad-request "invalid property schema") + (let [property (outliner-property/upsert-property! + conn nil + {:logseq.property/type (keyword (or (:type body) "default")) + :db/cardinality (keyword (or (:cardinality body) "db.cardinality/one"))} + {:property-name (:title body)})] + (property-response property))) + + :semantic/properties-get + (if-let [property (resolve-property db (:property-id path-params))] + (if (property? property) (property-response property) (not-found)) + (not-found)) + + :semantic/properties-update + (let [property (resolve-property db (:property-id path-params))] + (if (or (not (property? property)) (empty? body) (not (valid-property-schema? body))) + (bad-request "invalid property-id or update") + (let [schema (cond-> {} + (:type body) (assoc :logseq.property/type (keyword (:type body))) + (:cardinality body) (assoc :db/cardinality (keyword (:cardinality body)))) + updated (outliner-property/upsert-property! + conn (:db/ident property) schema + (cond-> {} (:title body) (assoc :property-name (:title body))))] + (property-response updated)))) + + :semantic/properties-delete + (if-let [property (resolve-property db (:property-id path-params))] + (if-not (property? property) + (not-found) + (if (outliner-page/delete! conn (:block/uuid property)) + nil + (bad-request "property cannot be deleted"))) + (not-found)) + + :semantic/search + (let [query (:q query-params)] + (if-not (seq query) + (bad-request "missing q") + (paginated-response query-params :results + (search-results db query (:types query-params)) + search-result-response + paginate-bounded))))) + +(def ^:private page-handlers + #{:semantic/pages-list :semantic/pages-create :semantic/pages-blocks :semantic/pages-references :semantic/pages-get + :semantic/pages-update :semantic/pages-delete}) + +(def ^:private block-handlers + #{:semantic/blocks-get :semantic/blocks-update :semantic/blocks-delete :semantic/blocks-move + :semantic/blocks-insert-children :semantic/blocks-insert-tree}) + +(def ^:private block-property-handlers + #{:semantic/blocks-set-property :semantic/blocks-delete-property + :semantic/blocks-batch-set-property :semantic/blocks-batch-delete-property}) + +(def ^:private capture-and-tag-handlers + #{:semantic/capture :semantic/tags-list :semantic/tags-create :semantic/tags-get :semantic/tags-objects + :semantic/tags-update :semantic/tags-delete}) + +(def ^:private task-handlers + #{:semantic/tasks-list :semantic/tasks-create}) + +(def ^:private asset-handlers + #{:semantic/assets-list :semantic/assets-create :semantic/assets-get}) + +(def ^:private property-and-search-handlers + #{:semantic/properties-list :semantic/properties-create :semantic/properties-get + :semantic/properties-update :semantic/properties-delete + :semantic/search}) + +(defn- dispatch [{:keys [handler] :as context}] + (cond + (contains? page-handlers handler) (handle-pages context) + (contains? block-handlers handler) (handle-blocks context) + (contains? block-property-handlers handler) (handle-block-properties context) + (contains? capture-and-tag-handlers handler) (handle-capture-and-tags context) + (contains? task-handlers handler) (handle-tasks context) + (contains? asset-handlers handler) (handle-assets context) + (contains? property-and-search-handlers handler) (handle-properties-and-search context) + :else (not-found))) + +(def-thread-api :thread-api/semantic-request + [repo handler path-params query-params body] + (if-let [conn (worker-state/get-datascript-conn repo)] + (dispatch {:repo repo + :conn conn + :db @conn + :handler handler + :path-params (or path-params {}) + :query-params (or query-params {}) + :body body}) + (throw (api-error 404 :repo-not-found "repo not found")))) diff --git a/src/main/frontend/worker/semantic_routes.cljs b/src/main/frontend/worker/semantic_routes.cljs new file mode 100644 index 0000000..2238fda --- /dev/null +++ b/src/main/frontend/worker/semantic_routes.cljs @@ -0,0 +1,397 @@ +(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 ^:private operation-docs + {"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)}) diff --git a/src/test/frontend/worker/semantic_test.cljs b/src/test/frontend/worker/semantic_test.cljs new file mode 100644 index 0000000..d2f3234 --- /dev/null +++ b/src/test/frontend/worker/semantic_test.cljs @@ -0,0 +1,222 @@ +(ns frontend.worker.semantic-test + "Exercises the semantic API (Fase 9) over real HTTP against a live daemon, + the same way db_worker_node_test.cljs exercises /v1/invoke -- these + operations were ported from deps/db-sync/.../handler/semantic.cljs onto + frontend.worker.handler.semantic, re-hosted on the daemon's own + authoritative conn instead of a Cloudflare Durable Object's SQLite." + (:require ["http" :as http] + [cljs.test :refer [async deftest is]] + [frontend.test.node-helper :as node-helper] + [frontend.worker.db-worker-node :as db-worker-node])) + +;; Same literal credential db_worker_node_test.cljs bootstraps with -- both +;; namespaces get `require`d into the same test.js bundle, and this is a +;; process-wide env var, so setting it to a different value here would win +;; the race against whichever namespace loads later and lock the other +;; file's daemons out of their own bootstrapped credential. +(def ^:private test-daemon-username "db-worker-node-test-user") +(def ^:private test-daemon-password "db-worker-node-test-password") + +(aset (.-env js/process) "LOGSEQ_DAEMON_USERNAME" test-daemon-username) +(aset (.-env js/process) "LOGSEQ_DAEMON_PASSWORD" test-daemon-password) + +(defn- http-request + [opts body] + (js/Promise. + (fn [resolve reject] + (let [req (.request http (clj->js opts) + (fn [^js res] + (let [chunks (array)] + (.on res "data" (fn [chunk] (.push chunks chunk))) + (.on res "end" (fn [] + (resolve {:status (.-statusCode res) + :body (.toString (js/Buffer.concat chunks) "utf8")}))))))] + (.on req "error" reject) + (when body (.write req body)) + (.end req))))) + +(defn- json-body [{:keys [body]}] + (when-not (= "" body) + (js->clj (js/JSON.parse body) :keywordize-keys true))) + +(defn- auth-headers! + [host port] + (.then (http-request {:hostname host + :port port + :path "/v1/auth/login" + :method "POST" + :headers {"Content-Type" "application/json"}} + (js/JSON.stringify + (clj->js {:username test-daemon-username + :password test-daemon-password + :deviceLabel "test"}))) + (fn [result] + {"Content-Type" "application/json" + "Authorization" (str "Bearer " (:token (json-body result)))}))) + +(defn- semantic-request + ([host port method path] (semantic-request host port method path nil)) + ([host port method path body] + (.then (auth-headers! host port) + (fn [headers] + (http-request {:hostname host + :port port + :path path + :method method + :headers headers} + (when body (js/JSON.stringify (clj->js body)))))))) + +(defn- start-daemon! + [opts] + (db-worker-node/start-daemon! (update opts :log-level #(or % "error")))) + +(defn- with-daemon! + "Starts a fresh daemon over a fresh repo, runs (f host port), and stops the + daemon afterwards regardless of outcome. Returns a promise for `async`." + [f] + (let [daemon (atom nil) + data-dir (node-helper/create-tmp-dir "semantic-api") + repo (str "logseq_db_semantic_" (subs (str (random-uuid)) 0 8))] + (-> (.then (start-daemon! {:root-dir data-dir :repo repo}) + (fn [{:keys [host port stop!]}] + (reset! daemon {:stop! stop!}) + (f host port))) + (.catch (fn [e] + (println "[semantic-test] error:" e) + (is false (str e)))) + (.finally (fn [] + (when-let [stop! (:stop! @daemon)] + (stop!))))))) + +(deftest semantic-pages-create-get-list-and-delete + (async done + (-> (with-daemon! + (fn [host port] + (-> (semantic-request host port "POST" "/v1/semantic/pages" {:title "Semantic Test Page"}) + (.then (fn [{:keys [status] :as result}] + (is (= 201 status)) + (let [page (json-body result)] + (is (= "Semantic Test Page" (:title page))) + (is (= "page" (:kind page))) + (.then (semantic-request host port "GET" (str "/v1/semantic/pages/" (:uuid page))) + (fn [{:keys [status] :as get-result}] + (is (= 200 status)) + (is (= "Semantic Test Page" (:title (json-body get-result)))) + page))))) + (.then (fn [page] + (.then (semantic-request host port "GET" "/v1/semantic/pages") + (fn [{:keys [status] :as list-result}] + (is (= 200 status)) + (is (some #(= (:uuid page) (:uuid %)) (:blocks (json-body list-result)))) + page)))) + (.then (fn [page] + (.then (semantic-request host port "DELETE" (str "/v1/semantic/pages/" (:uuid page))) + (fn [{:keys [status]}] + (is (= 204 status)) + (semantic-request host port "GET" (str "/v1/semantic/pages/" (:uuid page))))))) + (.then (fn [{:keys [status]}] + (is (= 404 status))))))) + (.finally done)))) + +(deftest semantic-blocks-property-and-capture-flow + (async done + (-> (with-daemon! + (fn [host port] + (-> (semantic-request host port "POST" "/v1/semantic/capture" {:blocks [{:title "Captured root" + :children [{:title "child"}]}]}) + (.then (fn [{:keys [status] :as result}] + (is (= 201 status)) + (let [body (json-body result) + root (first (:blocks body))] + (is (= "Captured root" (:title root))) + (is (= 1 (count (:children root)))) + root))) + (.then (fn [root] + (.then (semantic-request host port "POST" "/v1/semantic/properties" + {:title "semantic-test-prop" :type "string" + :cardinality "db.cardinality/one"}) + (fn [{:keys [status] :as property-result}] + (is (= 201 status)) + [root (json-body property-result)])))) + (.then (fn [[root property]] + (.then (semantic-request host port "PUT" + (str "/v1/semantic/blocks/" (:uuid root) + "/properties/" (:uuid property)) + {:value "hello"}) + (fn [{:keys [status] :as set-result}] + (is (= 200 status)) + (is (:updated (json-body set-result))) + [root property])))) + (.then (fn [[root property]] + (semantic-request host port "DELETE" + (str "/v1/semantic/blocks/" (:uuid root) + "/properties/" (:uuid property))))) + (.then (fn [{:keys [status]}] + (is (= 204 status))))))) + (.finally done)))) + +(deftest semantic-tags-and-tasks-flow + (async done + (-> (with-daemon! + (fn [host port] + (-> (semantic-request host port "POST" "/v1/semantic/tags" {:title "Semantic Tag"}) + (.then (fn [{:keys [status] :as result}] + (is (= 201 status)) + (is (= "Semantic Tag" (:title (json-body result)))))) + (.then (fn [_] + (semantic-request host port "POST" "/v1/semantic/tasks" + {:title "Semantic Task" :status "doing" :priority "high"}))) + (.then (fn [{:keys [status] :as result}] + (is (= 201 status)) + (let [task (json-body result)] + (is (= "logseq.property/status.doing" (get-in task [:status :ident]))) + (is (= "logseq.property/priority.high" (get-in task [:priority :ident])))))) + (.then (fn [_] + (semantic-request host port "GET" "/v1/semantic/tasks?status=doing"))) + (.then (fn [{:keys [status] :as result}] + (is (= 200 status)) + (is (some #(= "Semantic Task" (:title %)) (:tasks (json-body result))))))))) + (.finally done)))) + +(deftest semantic-search-finds-created-page + (async done + (-> (with-daemon! + (fn [host port] + (-> (semantic-request host port "POST" "/v1/semantic/pages" {:title "Findable Semantic Page"}) + (.then (fn [_] + (semantic-request host port "GET" "/v1/semantic/search?q=Findable"))) + (.then (fn [{:keys [status] :as result}] + (is (= 200 status)) + (is (some #(= "Findable Semantic Page" (:title %)) (:results (json-body result))))))))) + (.finally done)))) + +(deftest semantic-request-requires-auth + (async done + (-> (with-daemon! + (fn [host port] + (-> (http-request {:hostname host :port port :path "/v1/semantic/pages" :method "GET"} nil) + (.then (fn [{:keys [status]}] + (is (= 401 status))))))) + (.finally done)))) + +(deftest semantic-unknown-route-is-not-found + (async done + (-> (with-daemon! + (fn [host port] + (-> (semantic-request host port "GET" "/v1/semantic/not-a-real-route") + (.then (fn [{:keys [status]}] + (is (= 404 status))))))) + (.finally done)))) + +(deftest semantic-openapi-document-lists-operations + (async done + (-> (with-daemon! + (fn [host port] + (-> (semantic-request host port "GET" "/v1/semantic/openapi.json") + (.then (fn [{:keys [status] :as result}] + (is (= 200 status)) + (let [doc (json-body result)] + (is (= "3.1.0" (:openapi doc))) + (is (contains? (:paths doc) (keyword "/v1/semantic/pages"))) + (is (not (contains? (:paths doc) (keyword "/v1/graphs/{graph-id}/semantic/pages")))))))))) + (.finally done))))