From 78de33bf683cb4c50f73ebd1b110fcc4e1b75de7 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Fri, 13 Mar 2026 22:30:06 +0800 Subject: [PATCH] enhance: 'upsert block' support update --content and --status --- docs/cli/logseq-cli.md | 3 +- src/main/logseq/cli/command/add.cljs | 2 +- src/main/logseq/cli/command/update.cljs | 69 +++++++++++++++++------ src/main/logseq/cli/command/upsert.cljs | 4 +- src/test/logseq/cli/commands_test.cljs | 64 ++++++++++++++++++++- src/test/logseq/cli/integration_test.cljs | 48 ++++++++++++++++ 6 files changed, 165 insertions(+), 25 deletions(-) diff --git a/docs/cli/logseq-cli.md b/docs/cli/logseq-cli.md index bb36149e2e..8c19ca5ac6 100644 --- a/docs/cli/logseq-cli.md +++ b/docs/cli/logseq-cli.md @@ -182,7 +182,8 @@ Inspect and edit commands: - `upsert block --content [--target-page |--target-id |--target-uuid ] [--pos first-child|last-child|sibling]` - create blocks; defaults to today’s journal page if no target is given - `upsert block --blocks [--target-page |--target-id |--target-uuid ] [--pos first-child|last-child|sibling]` - insert blocks via EDN vector - `upsert block --blocks-file [--target-page |--target-id |--target-uuid ] [--pos first-child|last-child|sibling]` - insert blocks from an EDN file -- `upsert block --id |--uuid [--target-id |--target-uuid |--target-page ] [--pos first-child|last-child|sibling] [--update-tags ] [--update-properties ] [--remove-tags ] [--remove-properties ]` - update and/or move a block +- `upsert block --id |--uuid [--content ] [--status ] [--target-id |--target-uuid |--target-page ] [--pos first-child|last-child|sibling] [--update-tags ] [--update-properties ] [--remove-tags ] [--remove-properties ]` - 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 [--update-tags ] [--update-properties ] [--remove-tags ] [--remove-properties ]` - create (or update by page name) a page - `upsert page --id [--update-tags ] [--update-properties ] [--remove-tags ] [--remove-properties ]` - update a page by id (cannot be combined with `--page`) - `upsert tag --name ` - create or upsert a tag by name diff --git a/src/main/logseq/cli/command/add.cljs b/src/main/logseq/cli/command/add.cljs index 1bc62a057b..ef6c42fb49 100644 --- a/src/main/logseq/cli/command/add.cljs +++ b/src/main/logseq/cli/command/add.cljs @@ -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 ":")) diff --git a/src/main/logseq/cli/command/update.cljs b/src/main/logseq/cli/command/update.cljs index 8e3f5e8fa4..82cd745654 100644 --- a/src/main/logseq/cli/command/update.cljs +++ b/src/main/logseq/cli/command/update.cljs @@ -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] diff --git a/src/main/logseq/cli/command/upsert.cljs b/src/main/logseq/cli/command/upsert.cljs index a77b7a8cbb..b4583a69e1 100644 --- a/src/main/logseq/cli/command/upsert.cljs +++ b/src/main/logseq/cli/command/upsert.cljs @@ -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)"} diff --git a/src/test/logseq/cli/commands_test.cljs b/src/test/logseq/cli/commands_test.cljs index 420c778517..eac8e6ffb5 100644 --- a/src/test/logseq/cli/commands_test.cljs +++ b/src/test/logseq/cli/commands_test.cljs @@ -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))))) diff --git a/src/test/logseq/cli/integration_test.cljs b/src/test/logseq/cli/integration_test.cljs index c9e552e61d..9c41a48e09 100644 --- a/src/test/logseq/cli/integration_test.cljs +++ b/src/test/logseq/cli/integration_test.cljs @@ -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")]