Merge pull request #12707 from logseq/codex/fix-cli-block-inline-properties

fix(cli): handle inline block properties
This commit is contained in:
rcmerci
2026-05-24 14:45:35 +08:00
committed by GitHub
3 changed files with 377 additions and 5 deletions

View File

@@ -674,6 +674,77 @@
:upsert ["--blocks-file" "--target-id" "--pos"]}},
:tags [:upsert],
:extends :non-sync/graph-json-env}
{:id "block-upsert-blocks-file-inline-property-name-json",
:setup
["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null"
"{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert property --graph {{graph-arg}} --name 'Correct Answer' --type default --cardinality one --public true >/dev/null"
"printf '%s' '[{:block/title \"Question block\" \"Correct Answer\" \"answer\"}]' > {{export-path-arg}}"],
:cmds
["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page Home --blocks-file {{export-path-arg}} >/dev/null"
"{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output human show --graph {{graph-arg}} --page Home"],
:expect
{:exit 0,
:stdout-contains ["Question block" "Correct Answer:" "answer"]},
:covers
{:commands ["upsert block" "upsert property" "show"],
:options
{:global ["--config" "--graph" "--root-dir" "--output"],
:upsert ["--blocks-file" "--target-page" "--name" "--type" "--cardinality" "--public"],
:show ["--page"]}},
:tags [:upsert :show],
:extends :non-sync/graph-json-env}
{:id "block-upsert-blocks-inline-property-missing-does-not-create-target-page-json",
:cmds
["set +e; out=\"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page NewPage --blocks '[{:block/title \"Q\" \"Missing Prop\" \"x\"}]' 2>&1)\"; code=$?; set -e; printf '%s\n' \"$out\"; test \"$code\" -ne 0; printf '%s' \"$out\" | python3 -c 'import sys,json; data=json.load(sys.stdin); assert data[\"status\"] == \"error\"; assert data[\"error\"][\"code\"] == \"property-not-found\"'; page=\"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"NewPage\"]]')\"; printf '%s\n' \"$page\"; printf '%s' \"$page\" | python3 -c 'import sys,json; data=json.load(sys.stdin); assert data[\"data\"][\"result\"] is None'"],
:expect
{:exit 0,
:stdout-contains ["property-not-found"]},
:covers
{:commands ["upsert block" "query"],
:options
{:global ["--config" "--graph" "--root-dir" "--output"],
:upsert ["--blocks" "--target-page"],
:query ["--query"]}},
:tags [:upsert],
:extends :non-sync/graph-json-env}
{:id "block-upsert-blocks-inline-properties-per-block-json",
:setup
["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null"
"{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert property --graph {{graph-arg}} --name 'Inline Note' --type default --cardinality one --public true >/dev/null"],
:cmds
["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page Home --blocks '[{:block/title \"First inline\" \"Inline Note\" \"First note\"} {:block/title \"Second inline\" \"Inline Note\" \"Second note\"}]' >/dev/null"
"first_id=\"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"First inline\"]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\"; second_id=\"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"Second inline\"]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\"; first_show=\"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output human show --graph {{graph-arg}} --id \"$first_id\")\"; second_show=\"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output human show --graph {{graph-arg}} --id \"$second_id\")\"; printf '%s\n---\n%s\n' \"$first_show\" \"$second_show\"; printf '%s' \"$first_show\" | grep 'Inline Note: First note' >/dev/null; ! printf '%s' \"$first_show\" | grep 'Second note' >/dev/null; printf '%s' \"$second_show\" | grep 'Inline Note: Second note' >/dev/null; ! printf '%s' \"$second_show\" | grep 'First note' >/dev/null"],
:expect
{:exit 0,
:stdout-contains ["First inline" "Inline Note: First note" "Second inline" "Inline Note: Second note"]},
:covers
{:commands ["upsert block" "upsert property" "query" "show"],
:options
{:global ["--config" "--graph" "--root-dir" "--output"],
:upsert ["--blocks" "--target-page" "--name" "--type" "--cardinality" "--public"],
:query ["--query"],
:show ["--id"]}},
:tags [:upsert :show],
:extends :non-sync/graph-json-env}
{:id "block-upsert-blocks-inline-property-numeric-id-json",
:setup
["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null"
"{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert property --graph {{graph-arg}} --name 'Numeric Prop' --type default --cardinality one --public true >/dev/null"],
:cmds
["prop_id=\"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"Numeric Prop\"]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\"; {{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page Home --blocks \"[{:block/title \\\"Inline numeric\\\" $prop_id \\\"inline\\\"}]\" >/dev/null"
"{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output human show --graph {{graph-arg}} --page Home"],
:expect
{:exit 0,
:stdout-contains ["Inline numeric" "Numeric Prop: inline"]},
:covers
{:commands ["upsert block" "upsert property" "query" "show"],
:options
{:global ["--config" "--graph" "--root-dir" "--output"],
:upsert ["--blocks" "--target-page" "--name" "--type" "--cardinality" "--public"],
:query ["--query"],
:show ["--page"]}},
:tags [:upsert :show],
:extends :non-sync/graph-json-env}
{:id "block-upsert-target-uuid-json",
:setup
["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null"

View File

@@ -168,6 +168,80 @@
block)))
blocks))
(def ^:private inline-property-key-namespace-prefixes
["logseq.property" "user.property" "plugin.property"])
(defn- inline-property-key-namespace?
[key-namespace]
(boolean
(and key-namespace
(some (fn [prefix]
(or (= key-namespace prefix)
(string/starts-with? key-namespace (str prefix "."))))
inline-property-key-namespace-prefixes))))
(defn- public-built-in-property-key?
[k]
(if-let [property (get db-property/built-in-properties k)]
(true? (get-in property [:schema :public?]))
true))
(defn- inline-property-key?
[k]
(cond
(string? k)
(seq (string/trim k))
(or (number? k) (uuid? k))
true
(qualified-keyword? k)
(and (inline-property-key-namespace? (namespace k))
(public-built-in-property-key? k))
(keyword? k)
true
:else
false))
(defn- split-inline-property-attrs
[block]
(reduce-kv (fn [acc k v]
(if (inline-property-key? k)
(update acc :properties assoc k v)
(update acc :block assoc k v)))
{:block {}
:properties {}}
block))
(defn- extract-inline-properties
[blocks]
(letfn [(walk-block [block]
(let [{block-without-inline-properties :block
inline-properties :properties} (split-inline-property-attrs block)
children (:block/children block-without-inline-properties)
walked-children (when (seq children)
(mapv walk-block children))
block' (if (seq walked-children)
(assoc block-without-inline-properties :block/children (mapv :block walked-children))
block-without-inline-properties)
block-uuid (:block/uuid block')]
(when (and (seq inline-properties) (nil? block-uuid))
(throw (ex-info "block uuid is required for inline properties"
{:code :missing-block-uuid-for-inline-properties
:properties (keys inline-properties)})))
{:block block'
:property-assignments (cond-> []
(seq inline-properties)
(conj {:block-uuid block-uuid
:properties inline-properties})
(seq walked-children)
(into (mapcat :property-assignments walked-children)))}))]
(let [walked (mapv walk-block blocks)]
{:blocks (mapv :block walked)
:property-assignments (vec (mapcat :property-assignments walked))})))
(defn- normalize-created-ids
[ids]
(->> ids
@@ -1063,6 +1137,27 @@
properties))]
(vec resolved-entries)))))
(defn- resolve-inline-property-assignments
[config repo property-assignments]
(if (seq property-assignments)
(p/let [resolved (p/all
(map (fn [{:keys [block-uuid properties]}]
(p/let [resolved-properties (resolve-properties config repo properties
{:allow-non-built-in? true})]
{:block-uuid block-uuid
:properties resolved-properties}))
property-assignments))]
(vec resolved))
(p/resolved nil)))
(defn- inline-property-assignment-ops
[property-assignments]
(mapcat (fn [{:keys [block-uuid properties]}]
(map (fn [[property-ident value]]
[:batch-set-property [[block-uuid] property-ident value {}]])
properties))
property-assignments))
(defn- resolve-add-target
[config {:keys [repo target-id target-uuid target-page-name]}]
(cond
@@ -1163,17 +1258,21 @@
(defn execute-add-block
[action config]
(-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
target-block-uuid (resolve-add-target cfg action)
action-blocks (normalize-block-content-keys (:blocks action))
ref-values (collect-page-refs action-blocks)
{blocks-without-inline-properties :blocks
inline-property-assignments :property-assignments} (extract-inline-properties action-blocks)
inline-property-assignments (resolve-inline-property-assignments cfg (:repo action)
inline-property-assignments)
target-block-uuid (resolve-add-target cfg action)
ref-values (collect-page-refs blocks-without-inline-properties)
{:keys [uuid-refs page-refs id-refs]} (partition-ref-values ref-values)
_ (ensure-block-refs-exist! cfg (:repo action) uuid-refs)
page-refs' (or (resolve-page-ref-entities cfg (:repo action) page-refs) [])
id-refs' (or (resolve-id-ref-entities cfg (:repo action) id-refs) [])
refs (into page-refs' id-refs')
blocks (if (seq refs)
(normalize-block-title-refs action-blocks refs)
action-blocks)
(normalize-block-title-refs blocks-without-inline-properties refs)
blocks-without-inline-properties)
blocks-for-insert (flatten-block-tree blocks)
status (:status action)
tags (if (contains? action :resolved-tags)
@@ -1220,7 +1319,9 @@
(and (seq properties) (seq block-uuids))
(into (map (fn [[k v]]
[:batch-set-property [block-uuids k v {}]])
properties)))
properties))
(seq inline-property-assignments)
(into (inline-property-assignment-ops inline-property-assignments)))
apply-result (transport/invoke cfg :thread-api/apply-outliner-ops [(:repo action) ops {}])
created-ids (resolve-created-block-ids cfg (:repo action) blocks-for-insert apply-result)]
{:status :ok

View File

@@ -4,6 +4,7 @@
[cljs.test :refer [async deftest is testing]]
[clojure.string :as string]
[logseq.cli.command.add :as add-command]
[logseq.cli.server :as cli-server]
[logseq.common.util.date-time :as date-time-util]
[logseq.cli.transport :as transport]
[promesa.core :as p]))
@@ -96,6 +97,65 @@
(is (empty? (:id-refs result)))
(is (= ["3.14" "1e5"] (:page-refs result))))))
(deftest test-extract-inline-properties-from-blocks
(testing "removes inline property attrs while preserving block uuids and children"
(let [root-uuid (uuid "00000000-0000-0000-0000-000000000101")
child-uuid (uuid "00000000-0000-0000-0000-000000000102")
result (#'add-command/extract-inline-properties
[{:block/title "Root"
:block/uuid root-uuid
:block/page [:block/name "Home"]
:block/tags [1]
:user.property/root-answer "root"
"Correct Answer" "named value"
:logseq.property/status :logseq.property/status.todo
:block/children [{:block/title "Child"
:block/uuid child-uuid
:user.property/child-answer "child"}]}])]
(is (= [{:block/title "Root"
:block/uuid root-uuid
:block/page [:block/name "Home"]
:block/tags [1]
:block/children [{:block/title "Child"
:block/uuid child-uuid}]}]
(:blocks result)))
(is (= [{:block-uuid root-uuid
:properties {:user.property/root-answer "root"
"Correct Answer" "named value"
:logseq.property/status :logseq.property/status.todo}}
{:block-uuid child-uuid
:properties {:user.property/child-answer "child"}}]
(:property-assignments result))))))
(deftest test-extract-inline-properties-keeps-non-property-attrs
(testing "property selectors are extracted without treating normal block attrs as properties"
(let [block-uuid (uuid "00000000-0000-0000-0000-000000000103")
property-uuid (uuid "00000000-0000-0000-0000-000000000104")
result (#'add-command/extract-inline-properties
[{:block/title "Block"
:block/uuid block-uuid
:db/id 101
:build/keep-uuid? true
:plugin/option "kept"
:logseq.property.asset/type "png"
:plugin.property._test_plugin/rating "5"
801 "by id"
property-uuid "by uuid"
:plain-title "by keyword"}])]
(is (= [{:block/title "Block"
:block/uuid block-uuid
:db/id 101
:build/keep-uuid? true
:plugin/option "kept"
:logseq.property.asset/type "png"}]
(:blocks result)))
(is (= [{:block-uuid block-uuid
:properties {:plugin.property._test_plugin/rating "5"
801 "by id"
property-uuid "by uuid"
:plain-title "by keyword"}}]
(:property-assignments result))))))
(def ^:private mock-transport-invoke
(fn [_ method args]
(case method
@@ -302,3 +362,143 @@
(p/catch (fn [e]
(is false (str "unexpected error: " e))))
(p/finally done))))
(deftest test-execute-add-block-applies-inline-properties-per-block
(async done
(let [target-uuid (uuid "00000000-0000-0000-0000-000000000200")
root-uuid (uuid "00000000-0000-0000-0000-000000000201")
child-uuid (uuid "00000000-0000-0000-0000-000000000202")
ops* (atom nil)
resolve-properties-calls* (atom [])]
(-> (p/with-redefs [cli-server/ensure-server! (fn [_ _] {:base-url "http://example"})
add-command/resolve-tags (fn [_ _ _] (p/resolved nil))
add-command/resolve-properties
(fn [_ _ properties & [opts]]
(swap! resolve-properties-calls* conj {:properties properties
:opts opts})
(p/resolved properties))
add-command/resolve-property-identifiers (fn [_ _ _ & _] (p/resolved nil))
transport/invoke
(fn [_ method args]
(case method
:thread-api/pull
(let [[_ _ lookup] args]
(p/resolved
(cond
(= lookup 900)
{:db/id 900 :block/uuid target-uuid}
(= lookup [:block/uuid root-uuid])
{:db/id 901 :block/uuid root-uuid}
(= lookup [:block/uuid child-uuid])
{:db/id 902 :block/uuid child-uuid}
:else {})))
:thread-api/apply-outliner-ops
(let [[_ ops _] args]
(reset! ops* ops)
(p/resolved {:result :ok}))
(p/rejected (ex-info "unexpected invoke" {:method method :args args}))))]
(p/let [result (add-command/execute-add-block
{:type :add-block
:repo "demo"
:target-id 900
:pos "last-child"
:blocks [{:block/title "Root"
:block/uuid root-uuid
:user.property/root-answer "root"
:block/children [{:block/title "Child"
:block/uuid child-uuid
:user.property/child-answer "child"}]}]}
{})]
(is (= :ok (:status result)))
(is (= [901 902] (get-in result [:data :result])))
(let [ops @ops*
insert-op (first ops)
inserted-blocks (get-in insert-op [1 0])]
(is (= :insert-blocks (first insert-op)))
(is (= [{:block/title "Root"
:block/uuid root-uuid}
{:block/title "Child"
:block/uuid child-uuid
:block/parent [:block/uuid root-uuid]}]
inserted-blocks))
(is (some #(= [:batch-set-property [[root-uuid]
:user.property/root-answer
"root"
{}]]
%)
ops))
(is (some #(= [:batch-set-property [[child-uuid]
:user.property/child-answer
"child"
{}]]
%)
ops))
(is (some #(= {:properties {:user.property/root-answer "root"}
:opts {:allow-non-built-in? true}}
%)
@resolve-properties-calls*))
(is (some #(= {:properties {:user.property/child-answer "child"}
:opts {:allow-non-built-in? true}}
%)
@resolve-properties-calls*)))))
(p/catch (fn [e]
(is false (str "unexpected error: " e))))
(p/finally done)))))
(deftest test-execute-add-block-does-not-create-target-page-when-inline-property-resolution-fails
(async done
(let [created-page-uuid (uuid "00000000-0000-0000-0000-000000000203")
ops* (atom [])]
(-> (p/with-redefs [cli-server/ensure-server! (fn [_ _] {:base-url "http://example"})
add-command/resolve-properties
(fn [_ _ properties & _]
(if (= {"Missing Prop" "x"} properties)
(p/rejected (ex-info "property not found: \"Missing Prop\""
{:code :property-not-found
:property "Missing Prop"}))
(p/resolved properties)))
transport/invoke
(fn [_ method args]
(case method
:thread-api/q
(p/resolved [])
:thread-api/pull
(let [[_ _ lookup] args]
(p/resolved
(if (= lookup [:block/name "newpage"])
{:db/id 900
:block/uuid created-page-uuid
:block/name "newpage"
:block/title "NewPage"}
{})))
:thread-api/apply-outliner-ops
(let [[_ ops _] args]
(swap! ops* conj ops)
(p/resolved {:result :ok}))
(p/rejected (ex-info "unexpected invoke" {:method method :args args}))))]
(-> (add-command/execute-add-block
{:type :add-block
:repo "demo"
:target-page-name "NewPage"
:pos "last-child"
:blocks [{:block/title "Q"
:block/uuid (uuid "00000000-0000-0000-0000-000000000204")
"Missing Prop" "x"}]}
{})
(p/then (fn [_]
(is false "expected inline property resolution error")))
(p/catch (fn [e]
(is (= :property-not-found (-> e ex-data :code)))
(is (empty? @ops*)
"target page creation must not run before inline properties validate")))))
(p/catch (fn [e]
(is false (str "unexpected error: " e))))
(p/finally done)))))