enhance: 'upsert block' support update --content and --status

This commit is contained in:
rcmerci
2026-03-13 22:30:06 +08:00
parent da4178d040
commit 78de33bf68
6 changed files with 165 additions and 25 deletions

View File

@@ -182,7 +182,8 @@ Inspect and edit commands:
- `upsert block --content <text> [--target-page <name>|--target-id <id>|--target-uuid <uuid>] [--pos first-child|last-child|sibling]` - create blocks; defaults to todays journal page if no target is given
- `upsert block --blocks <edn> [--target-page <name>|--target-id <id>|--target-uuid <uuid>] [--pos first-child|last-child|sibling]` - insert blocks via EDN vector
- `upsert block --blocks-file <path> [--target-page <name>|--target-id <id>|--target-uuid <uuid>] [--pos first-child|last-child|sibling]` - insert blocks from an EDN file
- `upsert block --id <id>|--uuid <uuid> [--target-id <id>|--target-uuid <uuid>|--target-page <name>] [--pos first-child|last-child|sibling] [--update-tags <edn-vector>] [--update-properties <edn-map>] [--remove-tags <edn-vector>] [--remove-properties <edn-vector>]` - update and/or move a block
- `upsert block --id <id>|--uuid <uuid> [--content <text>] [--status <status>] [--target-id <id>|--target-uuid <uuid>|--target-page <name>] [--pos first-child|last-child|sibling] [--update-tags <edn-vector>] [--update-properties <edn-map>] [--remove-tags <edn-vector>] [--remove-properties <edn-vector>]` - update and/or move a block
- When both `--status` and `--update-properties` set `:logseq.property/status`, the value from `--update-properties` takes precedence.
- `upsert page --page <name> [--update-tags <edn-vector>] [--update-properties <edn-map>] [--remove-tags <edn-vector>] [--remove-properties <edn-vector>]` - create (or update by page name) a page
- `upsert page --id <id> [--update-tags <edn-vector>] [--update-properties <edn-map>] [--remove-tags <edn-vector>] [--remove-properties <edn-vector>]` - update a page by id (cannot be combined with `--page`)
- `upsert tag --name <name>` - create or upsert a tag by name

View File

@@ -57,7 +57,7 @@
"in progress" :logseq.property/status.doing
"inprogress" :logseq.property/status.doing})
(defn- normalize-status
(defn normalize-status
[value]
(let [text (some-> value string/trim)
parsed (when (and (seq text) (string/starts-with? text ":"))

View File

@@ -22,7 +22,14 @@
has-update-properties? (seq (some-> (:update-properties opts) string/trim))
has-remove-tags? (seq (some-> (:remove-tags opts) string/trim))
has-remove-properties? (seq (some-> (:remove-properties opts) string/trim))
has-updates? (or has-update-tags? has-update-properties? has-remove-tags? has-remove-properties?)]
has-status? (seq (some-> (:status opts) string/trim))
has-content? (contains? opts :content)
has-updates? (or has-update-tags?
has-update-properties?
has-remove-tags?
has-remove-properties?
has-status?
has-content?)]
(cond
(and (seq pos) (not (contains? update-positions pos)))
(str "invalid pos: " (:pos opts))
@@ -134,6 +141,12 @@
target-uuid (some-> (:target-uuid options) string/trim)
page-name (some-> (:target-page options) string/trim)
pos (some-> (:pos options) string/trim string/lower-case)
status-provided? (contains? options :status)
status-text (some-> (:status options) string/trim)
status (when (seq status-text)
(add-command/normalize-status status-text))
content-provided? (contains? options :content)
content (:content options)
update-tags-result (add-command/parse-tags-option (:update-tags options))
update-properties-result (add-command/parse-properties-option
(:update-properties options)
@@ -144,10 +157,19 @@
{:allow-non-built-in? true})
update-tags (:value update-tags-result)
update-properties (:value update-properties-result)
update-properties (merge (cond-> {}
status
(assoc :logseq.property/status status))
(or update-properties {}))
remove-tags (:value remove-tags-result)
remove-properties (:value remove-properties-result)
has-target? (or (some? target-id) (seq target-uuid) (seq page-name))
has-updates? (or (seq update-tags) (seq update-properties) (seq remove-tags) (seq remove-properties))
has-updates? (or (seq update-tags)
(seq update-properties)
(seq remove-tags)
(seq remove-properties)
status-provided?
content-provided?)
source-label (cond
(seq uuid) uuid
(some? id) (str id)
@@ -163,6 +185,11 @@
:error {:code :missing-source
:message "source block is required"}}
(and status-provided? (not status))
{:ok? false
:error {:code :invalid-options
:message (str "invalid status: " (:status options))}}
(and (not has-target?) (not has-updates?))
{:ok? false
:error {:code :invalid-options
@@ -182,21 +209,22 @@
:else
{:ok? true
:action {:type :update-block
:repo repo
:graph (core/repo->graph repo)
:id id
:uuid uuid
:target-id target-id
:target-uuid target-uuid
:target-page page-name
:pos (when has-target? (or pos "first-child"))
:update-tags update-tags
:update-properties update-properties
:remove-tags remove-tags
:remove-properties remove-properties
:source source-label
:target target-label}}))))
:action (cond-> {:type :update-block
:repo repo
:graph (core/repo->graph repo)
:id id
:uuid uuid
:target-id target-id
:target-uuid target-uuid
:target-page page-name
:pos (when has-target? (or pos "first-child"))
:update-tags update-tags
:update-properties update-properties
:remove-tags remove-tags
:remove-properties remove-properties
:source source-label
:target target-label}
content-provided? (assoc :content content))}))))
(defn execute-update
[action config]
@@ -217,12 +245,17 @@
{:allow-non-built-in? true})
block-id (:db/id source)
block-ids [block-id]
content-provided? (contains? action :content)
content (:content action)
update-tag-ids (when (seq update-tags)
(->> update-tags (map :db/id) (remove nil?) vec))
remove-tag-ids (when (seq remove-tags)
(->> remove-tags (map :db/id) (remove nil?) vec))
ops (cond-> []
target (conj [:move-blocks [[(:db/id source)] (:db/id target) opts]]))
content-provided?
(conj [:save-block [{:db/id block-id :block/title content} {}]])
target
(conj [:move-blocks [[(:db/id source)] (:db/id target) opts]]))
ops (cond-> ops
(seq remove-tag-ids)
(into (map (fn [tag-id]

View File

@@ -20,11 +20,11 @@
:complete :pages}
:pos {:desc "Position. Default: create=last-child, update=first-child"
:values ["first-child" "last-child" "sibling"]}
:content {:desc "Block content for create mode"}
:content {:desc "Block content (create inserts; update rewrites source block content)"}
:blocks {:desc "EDN vector of blocks for create mode"}
:blocks-file {:desc "EDN file of blocks for create mode"
:complete :file}
:status {:desc "Task status"
:status {:desc "Task status (create/update)"
:values ["todo" "doing" "done" "now" "later" "wait" "waiting"
"backlog" "canceled" "cancelled" "in-review" "in-progress"]}
:update-tags {:desc "Tags to add/update (EDN vector)"}

View File

@@ -1029,6 +1029,22 @@
(is (= :upsert-block (:command result)))
(is (= "abc" (get-in result [:options :uuid])))))
(testing "upsert block update mode accepts status-only updates"
(let [result (commands/parse-args ["upsert" "block" "--id" "1"
"--status" "done"])]
(is (true? (:ok? result)))
(is (= :upsert-block (:command result)))
(is (= 1 (get-in result [:options :id])))
(is (= "done" (get-in result [:options :status])))))
(testing "upsert block update mode accepts content-only updates"
(let [result (commands/parse-args ["upsert" "block" "--id" "1"
"--content" "updated text"])]
(is (true? (:ok? result)))
(is (= :upsert-block (:command result)))
(is (= 1 (get-in result [:options :id])))
(is (= "updated text" (get-in result [:options :content])))))
(testing "upsert block forces update mode when id and content are both provided"
(let [result (commands/parse-args ["upsert" "block"
"--id" "1"
@@ -1623,6 +1639,44 @@
(is (= :upsert-block (get-in result [:action :type])))
(is (= ["TagA"] (get-in result [:action :update-tags])))))
(testing "update accepts status-only updates"
(let [parsed {:ok? true
:command :upsert-block
:options {:id 1 :status "in-progress"}}
result (commands/build-action parsed {:graph "demo"})]
(is (true? (:ok? result)))
(is (= :upsert-block (get-in result [:action :type])))
(is (= :logseq.property/status.doing
(get-in result [:action :update-properties :logseq.property/status])))))
(testing "update accepts content-only updates"
(let [parsed {:ok? true
:command :upsert-block
:options {:id 1 :content "updated text"}}
result (commands/build-action parsed {:graph "demo"})]
(is (true? (:ok? result)))
(is (= :upsert-block (get-in result [:action :type])))
(is (= "updated text" (get-in result [:action :content])))))
(testing "update rejects invalid status"
(let [parsed {:ok? true
:command :upsert-block
:options {:id 1 :status "invalid-status"}}
result (commands/build-action parsed {:graph "demo"})]
(is (false? (:ok? result)))
(is (= :invalid-options (get-in result [:error :code])))))
(testing "update properties status takes precedence over --status"
(let [parsed {:ok? true
:command :upsert-block
:options {:id 1
:status "doing"
:update-properties "{:logseq.property/status :logseq.property/status.done}"}}
result (commands/build-action parsed {:graph "demo"})]
(is (true? (:ok? result)))
(is (= :logseq.property/status.done
(get-in result [:action :update-properties :logseq.property/status])))))
(testing "update rejects invalid update tags"
(let [parsed {:ok? true
:command :upsert-block
@@ -2200,9 +2254,11 @@
(let [ops* (atom nil)
calls* (atom [])
action {:type :upsert-block :mode :update :repo "demo" :id 1 :target-id 2 :pos "last-child"
:content "Updated heading"
:update-tags [:tag/new]
:remove-tags [:tag/old]
:update-properties {:logseq.property/deadline "2026-01-25T12:00:00Z"}
:update-properties {:logseq.property/deadline "2026-01-25T12:00:00Z"
:logseq.property/status :logseq.property/status.done}
:remove-properties [:logseq.property/publishing-public?]}]
(-> (p/with-redefs [cli-server/list-graphs (fn [_] ["demo"])
cli-server/ensure-server! (fn [_ _] {:base-url "http://example"})
@@ -2226,11 +2282,13 @@
(throw (ex-info "unexpected invoke" {:method method :calls @calls*}))))]
(p/let [result (commands/execute action {})]
(is (= :ok (:status result)))
(is (= [[:move-blocks [[1] 2 {:sibling? false :bottom? true}]]
(is (= [[:save-block [{:db/id 1 :block/title "Updated heading"} {}]]
[:move-blocks [[1] 2 {:sibling? false :bottom? true}]]
[:batch-delete-property-value [[1] :block/tags 202]]
[:batch-remove-property [[1] :logseq.property/publishing-public?]]
[:batch-set-property [[1] :block/tags 101 {}]]
[:batch-set-property [[1] :logseq.property/deadline "2026-01-25T12:00:00Z" {}]]]
[:batch-set-property [[1] :logseq.property/deadline "2026-01-25T12:00:00Z" {}]]
[:batch-set-property [[1] :logseq.property/status :logseq.property/status.done {}]]]
@ops*))))
(p/catch (fn [e] (is false (str "unexpected error: " e " calls: " @calls*))))
(p/finally done)))))

View File

@@ -2043,6 +2043,54 @@
(is false (str "unexpected error: " e))
(done)))))))
(deftest ^:long test-cli-upsert-block-update-content-and-status
(async done
(let [data-dir (node-helper/create-tmp-dir "db-worker-update-content-status")]
(-> (p/let [cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn")
_ (fs/writeFileSync cfg-path "{:output-format :json}")
_ (run-cli ["graph" "create" "--graph" "update-content-status-graph"] data-dir cfg-path)
_ (run-cli ["--graph" "update-content-status-graph" "upsert" "page" "--page" "Tasks"] data-dir cfg-path)
create-result (run-cli ["--graph" "update-content-status-graph"
"upsert" "block"
"--target-page" "Tasks"
"--content" "Task Seed"
"--status" "todo"]
data-dir cfg-path)
create-payload (parse-json-output create-result)
block-id (first-result-id create-payload)
update-result (run-cli ["--graph" "update-content-status-graph"
"upsert" "block"
"--id" (str block-id)
"--content" "Task Updated"
"--status" "done"]
data-dir cfg-path)
update-payload (parse-json-output update-result)
_ (p/delay 100)
query-title-result (run-cli ["--graph" "update-content-status-graph"
"query"
"--query" "[:find ?title . :in $ ?id :where [?id :block/title ?title]]"
"--inputs" (pr-str [block-id])]
data-dir cfg-path)
query-title-payload (parse-json-output query-title-result)
query-status-result (run-cli ["--graph" "update-content-status-graph"
"query"
"--query" "[:find ?ident . :in $ ?id :where [?id :logseq.property/status ?status] [?status :db/ident ?ident]]"
"--inputs" (pr-str [block-id])]
data-dir cfg-path)
query-status-payload (parse-json-output query-status-result)
stop-result (run-cli ["server" "stop" "--graph" "update-content-status-graph"] data-dir cfg-path)
stop-payload (parse-json-output stop-result)]
(is (= "ok" (:status create-payload)))
(is (some? block-id))
(is (= "ok" (:status update-payload)))
(is (= "Task Updated" (get-in query-title-payload [:data :result])))
(is (string/includes? (str (get-in query-status-payload [:data :result])) "status.done"))
(is (= "ok" (:status stop-payload)))
(done))
(p/catch (fn [e]
(is false (str "unexpected error: " e))
(done)))))))
(deftest ^:long test-cli-add-block-pos-ordering
(async done
(let [data-dir (node-helper/create-tmp-dir "db-worker-add-pos")]