diff --git a/docs/cli/logseq-cli.md b/docs/cli/logseq-cli.md index 4022aa95f2..b4b074f417 100644 --- a/docs/cli/logseq-cli.md +++ b/docs/cli/logseq-cli.md @@ -67,6 +67,7 @@ Inspect and edit commands: - `add block --blocks [--page ] [--parent ]` - insert blocks via EDN vector - `add block --blocks-file [--page ] [--parent ]` - insert blocks from an EDN file - `add page --page ` - create a page +- `move --id |--uuid --target-id |--target-uuid |--page-name [--pos first-child|last-child|sibling]` - move a block and its children (defaults to first-child) - `remove block --block ` - remove a block and its children - `remove page --page ` - remove a page and its children - `search --text [--type page|block|tag|property|all] [--include-content] [--limit ]` - search across pages, blocks, tags, and properties @@ -83,6 +84,7 @@ Subcommands: list property [options] List properties add block [options] Add blocks add page [options] Create page + move [options] Move block remove block [options] Remove block remove page [options] Remove page search [options] Search graph @@ -116,6 +118,7 @@ node ./static/logseq-cli.js graph create --repo demo node ./static/logseq-cli.js graph export --type edn --output /tmp/demo.edn --repo demo node ./static/logseq-cli.js graph import --type edn --input /tmp/demo.edn --repo demo-import node ./static/logseq-cli.js add block --page TestPage --content "hello world" +node ./static/logseq-cli.js move --uuid --page-name TargetPage node ./static/logseq-cli.js search --text "hello" node ./static/logseq-cli.js show --page-name TestPage --format json --output json node ./static/logseq-cli.js server list diff --git a/src/main/logseq/cli/command/core.cljs b/src/main/logseq/cli/command/core.cljs index 7fa0d7f1c6..407f6bf558 100644 --- a/src/main/logseq/cli/command/core.cljs +++ b/src/main/logseq/cli/command/core.cljs @@ -76,7 +76,7 @@ (defn top-level-summary [table] (let [groups [{:title "Graph Inspect and Edit" - :commands #{"list" "add" "remove" "search" "show"}} + :commands #{"list" "add" "remove" "move" "search" "show"}} {:title "Graph Management" :commands #{"graph" "server"}}] render-group (fn [{:keys [title commands]}] diff --git a/src/main/logseq/cli/command/move.cljs b/src/main/logseq/cli/command/move.cljs new file mode 100644 index 0000000000..4f57d0bc97 --- /dev/null +++ b/src/main/logseq/cli/command/move.cljs @@ -0,0 +1,181 @@ +(ns logseq.cli.command.move + "Move-related CLI commands." + (:require [clojure.string :as string] + [logseq.cli.command.core :as core] + [logseq.cli.server :as cli-server] + [logseq.cli.transport :as transport] + [logseq.common.util :as common-util] + [promesa.core :as p])) + +(def ^:private move-spec + {:id {:desc "Source block db/id" + :coerce :long} + :uuid {:desc "Source block UUID"} + :target-id {:desc "Target block db/id" + :coerce :long} + :target-uuid {:desc "Target block UUID"} + :page-name {:desc "Target page name"} + :pos {:desc "Position (first-child, last-child, sibling)"}}) + +(def entries + [(core/command-entry ["move"] :move-block "Move block" move-spec)]) + +(def ^:private move-positions + #{"first-child" "last-child" "sibling"}) + +(defn invalid-options? + [opts] + (let [pos (some-> (:pos opts) string/trim string/lower-case) + source-selectors (filter some? [(:id opts) (some-> (:uuid opts) string/trim)]) + target-selectors (filter some? [(:target-id opts) + (:target-uuid opts) + (some-> (:page-name opts) string/trim)])] + (cond + (and (seq pos) (not (contains? move-positions pos))) + (str "invalid pos: " (:pos opts)) + + (> (count source-selectors) 1) + "only one of --id or --uuid is allowed" + + (> (count target-selectors) 1) + "only one of --target-id, --target-uuid, or --page-name is allowed" + + (and (= pos "sibling") (seq (some-> (:page-name opts) string/trim))) + "--pos sibling is only valid for block targets" + + :else + nil))) + +(def ^:private block-selector + [:db/id :block/uuid :block/name :block/title]) + +(defn- fetch-entity-by-uuid + [config repo uuid-str] + (p/let [entity (transport/invoke config :thread-api/pull false + [repo block-selector [:block/uuid (uuid uuid-str)]])] + (if (:db/id entity) + entity + (transport/invoke config :thread-api/pull false + [repo block-selector [:block/uuid uuid-str]])))) + +(defn- ensure-non-page + [entity message code] + (if (:block/name entity) + (throw (ex-info message {:code code})) + entity)) + +(defn- resolve-source + [config repo {:keys [id uuid]}] + (cond + (some? id) + (p/let [entity (transport/invoke config :thread-api/pull false + [repo block-selector id])] + (if (:db/id entity) + (ensure-non-page entity "source must be a non-page block" :invalid-source) + (throw (ex-info "source block not found" {:code :source-not-found})))) + + (seq uuid) + (if-not (common-util/uuid-string? uuid) + (p/rejected (ex-info "source must be a uuid" {:code :invalid-source})) + (p/let [entity (fetch-entity-by-uuid config repo uuid)] + (if (:db/id entity) + (ensure-non-page entity "source must be a non-page block" :invalid-source) + (throw (ex-info "source block not found" {:code :source-not-found}))))) + + :else + (p/rejected (ex-info "source is required" {:code :missing-source})))) + +(defn- resolve-target + [config repo {:keys [target-id target-uuid page-name]}] + (cond + (some? target-id) + (p/let [entity (transport/invoke config :thread-api/pull false + [repo block-selector target-id])] + (if (:db/id entity) + (ensure-non-page entity "target must be a block" :invalid-target) + (throw (ex-info "target block not found" {:code :target-not-found})))) + + (seq target-uuid) + (if-not (common-util/uuid-string? target-uuid) + (p/rejected (ex-info "target must be a uuid" {:code :invalid-target})) + (p/let [entity (fetch-entity-by-uuid config repo target-uuid)] + (if (:db/id entity) + (ensure-non-page entity "target must be a block" :invalid-target) + (throw (ex-info "target block not found" {:code :target-not-found}))))) + + (seq page-name) + (p/let [entity (transport/invoke config :thread-api/pull false + [repo [:db/id :block/uuid :block/name :block/title] + [:block/name page-name]])] + (if (:db/id entity) + entity + (throw (ex-info "page not found" {:code :page-not-found})))) + + :else + (p/rejected (ex-info "target is required" {:code :missing-target})))) + +;; Position mapping for move-blocks opts. +(defn- pos->opts + [pos] + (case pos + "last-child" {:sibling? false :bottom? true} + "sibling" {:sibling? true} + {:sibling? false})) + +(defn build-action + [options repo] + (if-not (seq repo) + {:ok? false + :error {:code :missing-repo + :message "repo is required for move"}} + (let [id (:id options) + uuid (some-> (:uuid options) string/trim) + target-id (:target-id options) + target-uuid (some-> (:target-uuid options) string/trim) + page-name (some-> (:page-name options) string/trim) + pos (some-> (:pos options) string/trim string/lower-case) + source-label (cond + (seq uuid) uuid + (some? id) (str id) + :else nil) + target-label (cond + (seq page-name) (str "page:" page-name) + (seq target-uuid) target-uuid + (some? target-id) (str target-id) + :else nil)] + (cond + (not (or (some? id) (seq uuid))) + {:ok? false + :error {:code :missing-source + :message "source block is required"}} + + (not (or (some? target-id) (seq target-uuid) (seq page-name))) + {:ok? false + :error {:code :missing-target + :message "target is required"}} + + :else + {:ok? true + :action {:type :move-block + :repo repo + :graph (core/repo->graph repo) + :id id + :uuid uuid + :target-id target-id + :target-uuid target-uuid + :page-name page-name + :pos (or pos "first-child") + :source source-label + :target target-label}})))) + +(defn execute-move + [action config] + (-> (p/let [cfg (cli-server/ensure-server! config (:repo action)) + source (resolve-source cfg (:repo action) action) + target (resolve-target cfg (:repo action) action) + opts (pos->opts (:pos action)) + ops [[:move-blocks [[(:db/id source)] (:db/id target) opts]]] + result (transport/invoke cfg :thread-api/apply-outliner-ops false + [(:repo action) ops {}])] + {:status :ok + :data {:result result}}))) diff --git a/src/main/logseq/cli/commands.cljs b/src/main/logseq/cli/commands.cljs index bff5d14b9a..7c1d6aa3b5 100644 --- a/src/main/logseq/cli/commands.cljs +++ b/src/main/logseq/cli/commands.cljs @@ -6,6 +6,7 @@ [logseq.cli.command.core :as command-core] [logseq.cli.command.graph :as graph-command] [logseq.cli.command.list :as list-command] + [logseq.cli.command.move :as move-command] [logseq.cli.command.remove :as remove-command] [logseq.cli.command.search :as search-command] [logseq.cli.command.server :as server-command] @@ -46,6 +47,13 @@ :message "block or page is required"} :summary summary}) +(defn- missing-source-result + [summary] + {:ok? false + :error {:code :missing-source + :message "source block is required"} + :summary summary}) + (defn- missing-page-name-result [summary] {:ok? false @@ -90,6 +98,7 @@ server-command/entries list-command/entries add-command/entries + move-command/entries remove-command/entries search-command/entries show-command/entries))) @@ -112,7 +121,11 @@ (seq (:blocks opts)) (seq (:blocks-file opts)) has-args?) - show-targets (filter some? [(:id opts) (:uuid opts) (:page-name opts)])] + show-targets (filter some? [(:id opts) (:uuid opts) (:page-name opts)]) + move-sources (filter some? [(:id opts) (some-> (:uuid opts) string/trim)]) + move-targets (filter some? [(:target-id opts) + (some-> (:target-uuid opts) string/trim) + (some-> (:page-name opts) string/trim)])] (cond (:help opts) (command-core/help-result cmd-summary) @@ -133,6 +146,15 @@ (and (= command :remove-page) (not (seq (:page opts)))) (missing-target-result summary) + (and (= command :move-block) (move-command/invalid-options? opts)) + (command-core/invalid-options-result summary (move-command/invalid-options? opts)) + + (and (= command :move-block) (empty? move-sources)) + (missing-source-result summary) + + (and (= command :move-block) (empty? move-targets)) + (missing-target-result summary) + (and (= command :show) (empty? show-targets)) (missing-target-result summary) @@ -302,6 +324,9 @@ :add-page (add-command/build-add-page-action options repo) + :move-block + (move-command/build-action options repo) + :remove-block (remove-command/build-remove-block-action options repo) @@ -344,6 +369,7 @@ :list-property (list-command/execute-list-property action config) :add-block (add-command/execute-add-block action config) :add-page (add-command/execute-add-page action config) + :move-block (move-command/execute-move action config) :remove-block (remove-command/execute-remove action config) :remove-page (remove-command/execute-remove action config) :search (search-command/execute-search action config) @@ -358,4 +384,4 @@ :message "unknown action"}}))] (assoc result :command (or (:command action) (:type action)) - :context (select-keys action [:repo :graph :page :block :blocks]))))) + :context (select-keys action [:repo :graph :page :block :blocks :source :target]))))) diff --git a/src/main/logseq/cli/format.cljs b/src/main/logseq/cli/format.cljs index 4b872ea8d6..8c8869af92 100644 --- a/src/main/logseq/cli/format.cljs +++ b/src/main/logseq/cli/format.cljs @@ -218,6 +218,10 @@ [{:keys [repo block]}] (str "Removed block: " block " (repo: " repo ")")) +(defn- format-move-block + [{:keys [repo source target]}] + (str "Moved block: " source " -> " target " (repo: " repo ")")) + (defn- format-graph-export [{:keys [export-type output]}] (str "Exported " export-type " to " output)) @@ -256,6 +260,7 @@ :add-page (format-add-page context) :remove-page (format-remove-page context) :remove-block (format-remove-block context) + :move-block (format-move-block context) :graph-export (format-graph-export context) :graph-import (format-graph-import context) :search (format-search-results (:results data)) diff --git a/src/main/logseq/cli/main.cljs b/src/main/logseq/cli/main.cljs index eb5f1ab1cb..e540212a88 100644 --- a/src/main/logseq/cli/main.cljs +++ b/src/main/logseq/cli/main.cljs @@ -12,7 +12,7 @@ (string/join "\n" ["logseq [options]" "" - "Commands: list page, list tag, list property, add block, add page, remove block, remove page, search, show, graph list, graph create, graph switch, graph remove, graph validate, graph info, graph export, graph import, server list, server status, server start, server stop, server restart" + "Commands: list page, list tag, list property, add block, add page, move, remove block, remove page, search, show, graph list, graph create, graph switch, graph remove, graph validate, graph info, graph export, graph import, server list, server status, server start, server stop, server restart" "" "Options:" summary])) diff --git a/src/test/logseq/cli/commands_test.cljs b/src/test/logseq/cli/commands_test.cljs index 7d99d921ab..7bbb287748 100644 --- a/src/test/logseq/cli/commands_test.cljs +++ b/src/test/logseq/cli/commands_test.cljs @@ -17,6 +17,7 @@ (is (string/includes? summary "list")) (is (string/includes? summary "add")) (is (string/includes? summary "remove")) + (is (string/includes? summary "move")) (is (string/includes? summary "search")) (is (string/includes? summary "show")) (is (string/includes? summary "graph")) @@ -61,6 +62,13 @@ (is (string/includes? summary "remove block")) (is (string/includes? summary "remove page")))) + (testing "move command shows help" + (let [result (commands/parse-args ["move" "--help"]) + summary (:summary result)] + (is (true? (:help? result))) + (is (string/includes? summary "Usage: logseq move")) + (is (string/includes? summary "Command options:")))) + (testing "server group shows subcommands" (let [result (commands/parse-args ["server"]) summary (:summary result)] @@ -290,7 +298,25 @@ (let [result (commands/parse-args ["remove" "page" "--page" "Home"])] (is (true? (:ok? result))) (is (= :remove-page (:command result))) - (is (= "Home" (get-in result [:options :page])))))) + (is (= "Home" (get-in result [:options :page]))))) + + (testing "move requires source selector" + (let [result (commands/parse-args ["move" "--target-id" "10"])] + (is (false? (:ok? result))) + (is (= :missing-source (get-in result [:error :code]))))) + + (testing "move requires target selector" + (let [result (commands/parse-args ["move" "--id" "1"])] + (is (false? (:ok? result))) + (is (= :missing-target (get-in result [:error :code]))))) + + (testing "move parses with source and target" + (let [result (commands/parse-args ["move" "--uuid" "abc" "--target-uuid" "def" "--pos" "last-child"])] + (is (true? (:ok? result))) + (is (= :move-block (:command result))) + (is (= "abc" (get-in result [:options :uuid]))) + (is (= "def" (get-in result [:options :target-uuid]))) + (is (= "last-child" (get-in result [:options :pos])))))) (deftest test-verb-subcommand-parse-search-show (testing "search requires text" @@ -366,6 +392,7 @@ (doseq [args [["list" "page" "--wat"] ["add" "block" "--wat"] ["remove" "block" "--wat"] + ["move" "--wat"] ["search" "--wat"] ["show" "--wat"]]] (let [result (commands/parse-args args)] @@ -377,31 +404,13 @@ (is (true? (:ok? result))) (is (= "json" (get-in result [:options :output])))))) -(deftest test-build-action +(deftest test-build-action-graph (testing "graph-list uses list-db" (let [parsed {:ok? true :command :graph-list :options {}} result (commands/build-action parsed {})] (is (true? (:ok? result))) (is (= :graph-list (get-in result [:action :type]))))) - (testing "server list builds action" - (let [parsed {:ok? true :command :server-list :options {}} - result (commands/build-action parsed {})] - (is (true? (:ok? result))) - (is (= :server-list (get-in result [:action :type]))))) - - (testing "server start requires repo" - (let [parsed {:ok? true :command :server-start :options {}} - result (commands/build-action parsed {})] - (is (false? (:ok? result))) - (is (= :missing-repo (get-in result [:error :code]))))) - - (testing "server stop builds action" - (let [parsed {:ok? true :command :server-stop :options {:repo "demo"}} - result (commands/build-action parsed {})] - (is (true? (:ok? result))) - (is (= :server-stop (get-in result [:action :type]))))) - (testing "graph-create requires repo name" (let [parsed {:ok? true :command :graph-create :options {}} result (commands/build-action parsed {})] @@ -434,8 +443,28 @@ :options {:type "edn" :input "import.edn"}} result (commands/build-action parsed {})] (is (false? (:ok? result))) + (is (= :missing-repo (get-in result [:error :code])))))) + +(deftest test-build-action-server + (testing "server list builds action" + (let [parsed {:ok? true :command :server-list :options {}} + result (commands/build-action parsed {})] + (is (true? (:ok? result))) + (is (= :server-list (get-in result [:action :type]))))) + + (testing "server start requires repo" + (let [parsed {:ok? true :command :server-start :options {}} + result (commands/build-action parsed {})] + (is (false? (:ok? result))) (is (= :missing-repo (get-in result [:error :code]))))) + (testing "server stop builds action" + (let [parsed {:ok? true :command :server-stop :options {:repo "demo"}} + result (commands/build-action parsed {})] + (is (true? (:ok? result))) + (is (= :server-stop (get-in result [:action :type])))))) + +(deftest test-build-action-inspect-edit (testing "list page requires repo" (let [parsed {:ok? true :command :list-page :options {}} result (commands/build-action parsed {})] @@ -478,6 +507,40 @@ (is (false? (:ok? result))) (is (= :missing-target (get-in result [:error :code])))))) +(deftest test-build-action-move + (testing "move requires source selector" + (let [parsed {:ok? true :command :move-block :options {:target-id 2}} + result (commands/build-action parsed {:repo "demo"})] + (is (false? (:ok? result))) + (is (= :missing-source (get-in result [:error :code]))))) + + (testing "move requires target selector" + (let [parsed {:ok? true :command :move-block :options {:id 1}} + result (commands/build-action parsed {:repo "demo"})] + (is (false? (:ok? result))) + (is (= :missing-target (get-in result [:error :code])))))) + +(deftest test-move-parse-validation + (testing "move rejects multiple source selectors" + (let [result (commands/parse-args ["move" "--id" "1" "--uuid" "abc" "--target-id" "2"])] + (is (false? (:ok? result))) + (is (= :invalid-options (get-in result [:error :code]))))) + + (testing "move rejects multiple target selectors" + (let [result (commands/parse-args ["move" "--id" "1" "--target-id" "2" "--target-uuid" "def"])] + (is (false? (:ok? result))) + (is (= :invalid-options (get-in result [:error :code]))))) + + (testing "move rejects invalid position" + (let [result (commands/parse-args ["move" "--id" "1" "--target-id" "2" "--pos" "middle"])] + (is (false? (:ok? result))) + (is (= :invalid-options (get-in result [:error :code]))))) + + (testing "move rejects sibling pos for page target" + (let [result (commands/parse-args ["move" "--id" "1" "--page-name" "Home" "--pos" "sibling"])] + (is (false? (:ok? result))) + (is (= :invalid-options (get-in result [:error :code])))))) + (deftest test-execute-requires-existing-graph (async done (with-redefs [cli-server/list-graphs (fn [_] []) diff --git a/src/test/logseq/cli/format_test.cljs b/src/test/logseq/cli/format_test.cljs index 7ab050f1d7..a0c13c830a 100644 --- a/src/test/logseq/cli/format_test.cljs +++ b/src/test/logseq/cli/format_test.cljs @@ -103,7 +103,17 @@ :page "Home"} :data {:result {:ok true}}} {:output-format nil})] - (is (= "Removed page: Home (repo: demo-repo)" result))))) + (is (= "Removed page: Home (repo: demo-repo)" result)))) + + (testing "move block renders a succinct success line" + (let [result (format/format-result {:status :ok + :command :move-block + :context {:repo "demo-repo" + :source "source-uuid" + :target "target-uuid"} + :data {:result {:ok true}}} + {:output-format nil})] + (is (= "Moved block: source-uuid -> target-uuid (repo: demo-repo)" result))))) (deftest test-human-output-graph-import-export (testing "graph export renders a succinct success line" diff --git a/src/test/logseq/cli/integration_test.cljs b/src/test/logseq/cli/integration_test.cljs index fde2079428..82e8feb84c 100644 --- a/src/test/logseq/cli/integration_test.cljs +++ b/src/test/logseq/cli/integration_test.cljs @@ -30,6 +30,21 @@ [result] (reader/read-string (:output result))) +(defn- node-title + [node] + (or (:block/title node) (:title node))) + +(defn- node-children + [node] + (or (:block/children node) (:children node))) + +(defn- find-block-by-title + [node title] + (when node + (if (= title (node-title node)) + node + (some #(find-block-by-title % title) (node-children node))))) + (deftest test-cli-graph-list (async done (let [data-dir (node-helper/create-tmp-dir "db-worker")] @@ -110,6 +125,38 @@ (is false (str "unexpected error: " e)) (done))))))) +(deftest test-cli-move-block + (async done + (let [data-dir (node-helper/create-tmp-dir "db-worker-move")] + (-> (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" "--repo" "move-graph"] data-dir cfg-path) + _ (run-cli ["--repo" "move-graph" "add" "page" "--page" "SourcePage"] data-dir cfg-path) + _ (run-cli ["--repo" "move-graph" "add" "page" "--page" "TargetPage"] data-dir cfg-path) + _ (run-cli ["--repo" "move-graph" "add" "block" "--page" "SourcePage" "--content" "Parent Block"] data-dir cfg-path) + source-show (run-cli ["--repo" "move-graph" "show" "--page-name" "SourcePage" "--format" "json"] data-dir cfg-path) + source-payload (parse-json-output source-show) + parent-node (find-block-by-title (get-in source-payload [:data :root]) "Parent Block") + parent-uuid (or (:block/uuid parent-node) (:uuid parent-node)) + _ (run-cli ["--repo" "move-graph" "add" "block" "--parent" (str parent-uuid) "--content" "Child Block"] data-dir cfg-path) + move-result (run-cli ["--repo" "move-graph" "move" "--uuid" (str parent-uuid) "--page-name" "TargetPage"] data-dir cfg-path) + move-payload (parse-json-output move-result) + target-show (run-cli ["--repo" "move-graph" "show" "--page-name" "TargetPage" "--format" "json"] data-dir cfg-path) + target-payload (parse-json-output target-show) + moved-node (find-block-by-title (get-in target-payload [:data :root]) "Parent Block") + child-node (find-block-by-title moved-node "Child Block") + stop-result (run-cli ["server" "stop" "--repo" "move-graph"] data-dir cfg-path) + stop-payload (parse-json-output stop-result)] + (is (= "ok" (:status move-payload))) + (is (some? parent-uuid)) + (is (some? moved-node)) + (is (some? child-node)) + (is (= "ok" (:status stop-payload))) + (done)) + (p/catch (fn [e] + (is false (str "unexpected error: " e)) + (done))))))) + (deftest test-cli-output-formats-graph-list (async done (let [data-dir (node-helper/create-tmp-dir "db-worker")]