Просмотр исходного кода

impl 007-logseq-cli-thread-api-and-command-split.md

rcmerci 5 часов назад
Родитель
Сommit
efb44a2077

+ 51 - 28
src/main/frontend/worker/db_worker_node.cljs

@@ -68,6 +68,22 @@
     payload
     (ldb/write-transit-str payload)))
 
+(defn- normalize-method-kw
+  [method]
+  (cond
+    (keyword? method) method
+    (string? method) (keyword method)
+    (nil? method) nil
+    :else (keyword (str method))))
+
+(defn- normalize-method-str
+  [method]
+  (cond
+    (keyword? method) (subs (str method) 1)
+    (string? method) method
+    (nil? method) nil
+    :else (str method)))
+
 (defn- handle-event!
   [type payload]
   (let [event (js/JSON.stringify (clj->js {:type type}
@@ -90,7 +106,7 @@
                      (swap! *sse-clients disj res))))
 
 (defn- <invoke!
-  [^js proxy method direct-pass? args]
+  [^js proxy method-str method-kw direct-pass? args]
   (let [args' (if direct-pass?
                 (into-array (or args []))
                 (if (string? args)
@@ -100,22 +116,24 @@
         timeout-id (js/setTimeout
                     (fn []
                       (log/warn :db-worker-node-invoke-timeout
-                                {:method method
+                                {:method (or method-kw method-str)
                                  :elapsed-ms (- (js/Date.now) started-at)}))
                     10000)]
-    (-> (.remoteInvoke proxy method (boolean direct-pass?) args')
+    (-> (.remoteInvoke proxy method-str (boolean direct-pass?) args')
         (p/finally (fn []
                      (js/clearTimeout timeout-id))))))
 
 (defn- <init-worker!
   [proxy rtc-ws-url]
-  (<invoke! proxy "thread-api/init" true #js [rtc-ws-url]))
+  (let [method-kw :thread-api/init
+        method-str (normalize-method-str method-kw)]
+    (<invoke! proxy method-str method-kw true #js [rtc-ws-url])))
 
 (def ^:private non-repo-methods
-  #{"thread-api/init"
-    "thread-api/list-db"
-    "thread-api/get-version"
-    "thread-api/set-infer-worker-proxy"})
+  #{:thread-api/init
+    :thread-api/list-db
+    :thread-api/get-version
+    :thread-api/set-infer-worker-proxy})
 
 (defn- repo-arg
   [args]
@@ -126,23 +144,24 @@
 
 (defn- repo-error
   [method args bound-repo]
-  (when-not (contains? non-repo-methods method)
-    (let [repo (repo-arg args)]
-      (cond
-        (not (seq repo))
-        {:status 400
-         :error {:code :missing-repo
-                 :message "repo is required"}}
-
-        (not= repo bound-repo)
-        {:status 409
-         :error {:code :repo-mismatch
-                 :message "repo does not match bound repo"
-                 :repo repo
-                 :bound-repo bound-repo}}
-
-        :else
-        nil))))
+  (let [method-kw (normalize-method-kw method)]
+    (when-not (contains? non-repo-methods method-kw)
+      (let [repo (repo-arg args)]
+        (cond
+          (not (seq repo))
+          {:status 400
+           :error {:code :missing-repo
+                   :message "repo is required"}}
+
+          (not= repo bound-repo)
+          {:status 409
+           :error {:code :repo-mismatch
+                   :message "repo does not match bound repo"
+                   :repo repo
+                   :bound-repo bound-repo}}
+
+          :else
+          nil)))))
 
 (defn- set-main-thread-stub!
   []
@@ -177,6 +196,8 @@
              (-> (p/let [body (<read-body req)
                          payload (js/JSON.parse body)
                          {:keys [method directPass argsTransit args]} (js->clj payload :keywordize-keys true)
+                         method-kw (normalize-method-kw method)
+                         method-str (normalize-method-str method)
                          direct-pass? (boolean directPass)
                          args' (if direct-pass?
                                  args
@@ -186,9 +207,9 @@
                                                (if (string? args')
                                                  (ldb/read-transit-str args')
                                                  args'))]
-                   (if-let [{:keys [status error]} (repo-error method args-for-validation bound-repo)]
+                   (if-let [{:keys [status error]} (repo-error method-kw args-for-validation bound-repo)]
                      (send-json! res status {:ok false :error error})
-                     (p/let [result (<invoke! proxy method direct-pass? args')]
+                     (p/let [result (<invoke! proxy method-str method-kw direct-pass? args')]
                        (send-json! res 200 (if direct-pass?
                                              {:ok true :result result}
                                              {:ok true :resultTransit result})))))
@@ -311,7 +332,9 @@
                                                                :host host
                                                                :port port})
                     _ (reset! *lock-info {:path path :lock lock})
-                    _ (<invoke! proxy "thread-api/create-or-open-db" false [repo {}])]
+                    _ (let [method-kw :thread-api/create-or-open-db
+                            method-str (normalize-method-str method-kw)]
+                        (<invoke! proxy method-str method-kw false [repo {}]))]
               (let [stop!* (atom nil)
                     server (make-server proxy {:auth-token auth-token
                                                :bound-repo repo

+ 147 - 0
src/main/logseq/cli/command/add.cljs

@@ -0,0 +1,147 @@
+(ns logseq.cli.command.add
+  "Add-related CLI commands."
+  (:require ["fs" :as fs]
+            [cljs-time.coerce :as tc]
+            [cljs.reader :as reader]
+            [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]
+            [logseq.common.util.date-time :as date-time-util]
+            [promesa.core :as p]))
+
+(def ^:private content-add-spec
+  {:content {:desc "Block content for add"}
+   :blocks {:desc "EDN vector of blocks for add"}
+   :blocks-file {:desc "EDN file of blocks for add"}
+   :page {:desc "Page name"}
+   :parent {:desc "Parent block UUID for add"}})
+
+(def ^:private add-page-spec
+  {:page {:desc "Page name"}})
+
+(def entries
+  [(core/command-entry ["add" "block"] :add-block "Add blocks" content-add-spec)
+   (core/command-entry ["add" "page"] :add-page "Create page" add-page-spec)])
+
+(defn- today-page-title
+  [config repo]
+  (p/let [journal (transport/invoke config :thread-api/pull false
+                                    [repo [:logseq.property.journal/title-format] :logseq.class/Journal])
+          formatter (or (:logseq.property.journal/title-format journal) "MMM do, yyyy")
+          now (tc/from-date (js/Date.))]
+    (date-time-util/format now formatter)))
+
+(defn- ensure-page!
+  [config repo page-name]
+  (p/let [page (transport/invoke config :thread-api/pull false
+                                 [repo [:db/id :block/uuid :block/name :block/title] [:block/name page-name]])]
+    (if (:db/id page)
+      page
+      (p/let [_ (transport/invoke config :thread-api/apply-outliner-ops false
+                                  [repo [[:create-page [page-name {}]]] {}])]
+        (transport/invoke config :thread-api/pull false
+                          [repo [:db/id :block/uuid :block/name :block/title] [:block/name page-name]])))))
+
+(defn- resolve-add-target
+  [config {:keys [repo page parent]}]
+  (if (seq parent)
+    (if-not (common-util/uuid-string? parent)
+      (p/rejected (ex-info "parent must be a uuid" {:code :invalid-parent}))
+      (p/let [block (transport/invoke config :thread-api/pull false
+                                      [repo [:db/id :block/uuid :block/title] [:block/uuid (uuid parent)]])]
+        (if-let [id (:db/id block)]
+          id
+          (throw (ex-info "parent block not found" {:code :parent-not-found})))))
+    (p/let [page-name (if (seq page) page (today-page-title config repo))
+            page-entity (ensure-page! config repo page-name)]
+      (or (:db/id page-entity)
+          (throw (ex-info "page not found" {:code :page-not-found}))))))
+
+(defn- read-blocks
+  [options command-args]
+  (cond
+    (seq (:blocks options))
+    {:ok? true :value (reader/read-string (:blocks options))}
+
+    (seq (:blocks-file options))
+    (let [contents (.toString (fs/readFileSync (:blocks-file options)) "utf8")]
+      {:ok? true :value (reader/read-string contents)})
+
+    (seq (:content options))
+    {:ok? true :value [{:block/title (:content options)}]}
+
+    (seq command-args)
+    {:ok? true :value [{:block/title (string/join " " command-args)}]}
+
+    :else
+    {:ok? false
+     :error {:code :missing-content
+             :message "content is required"}}))
+
+(defn- ensure-blocks
+  [value]
+  (if (vector? value)
+    {:ok? true :value value}
+    {:ok? false
+     :error {:code :invalid-blocks
+             :message "blocks must be a vector"}}))
+
+(defn build-add-block-action
+  [options args repo]
+  (if-not (seq repo)
+    {:ok? false
+     :error {:code :missing-repo
+             :message "repo is required for add"}}
+    (let [blocks-result (read-blocks options args)]
+      (if-not (:ok? blocks-result)
+        blocks-result
+        (let [vector-result (ensure-blocks (:value blocks-result))]
+          (if-not (:ok? vector-result)
+            vector-result
+            {:ok? true
+             :action {:type :add-block
+                      :repo repo
+                      :graph (core/repo->graph repo)
+                      :page (:page options)
+                      :parent (:parent options)
+                      :blocks (:value vector-result)}}))))))
+
+(defn build-add-page-action
+  [options repo]
+  (if-not (seq repo)
+    {:ok? false
+     :error {:code :missing-repo
+             :message "repo is required for add"}}
+    (let [page (some-> (:page options) string/trim)]
+      (if (seq page)
+        {:ok? true
+         :action {:type :add-page
+                  :repo repo
+                  :graph (core/repo->graph repo)
+                  :page page}}
+        {:ok? false
+         :error {:code :missing-page-name
+                 :message "page name is required"}}))))
+
+(defn execute-add-block
+  [action config]
+  (-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
+              target-id (resolve-add-target cfg action)
+              ops [[:insert-blocks [(:blocks action)
+                                    target-id
+                                    {:sibling? false
+                                     :bottom? true
+                                     :outliner-op :insert-blocks}]]]
+              result (transport/invoke cfg :thread-api/apply-outliner-ops false [(:repo action) ops {}])]
+        {:status :ok
+         :data {:result result}})))
+
+(defn execute-add-page
+  [action config]
+  (-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
+              ops [[:create-page [(:page action) {}]]]
+              result (transport/invoke cfg :thread-api/apply-outliner-ops false [(:repo action) ops {}])]
+        {:status :ok
+         :data {:result result}})))

+ 216 - 0
src/main/logseq/cli/command/core.cljs

@@ -0,0 +1,216 @@
+(ns logseq.cli.command.core
+  "Shared CLI parsing utilities."
+  (:require [babashka.cli :as cli]
+            [clojure.string :as string]
+            [logseq.common.config :as common-config]))
+
+(def ^:private global-spec*
+  {:help {:alias :h
+          :desc "Show help"
+          :coerce :boolean}
+   :config {:desc "Path to cli.edn"}
+   :auth-token {:desc "Auth token for db-worker-node"}
+   :repo {:desc "Graph name"}
+   :data-dir {:desc "Path to db-worker data dir"}
+   :timeout-ms {:desc "Request timeout in ms"
+                :coerce :long}
+   :retries {:desc "Retry count for requests"
+             :coerce :long}
+   :output {:desc "Output format (human, json, edn)"}})
+
+(defn global-spec
+  []
+  global-spec*)
+
+(defn- merge-spec
+  [spec]
+  (merge global-spec* (or spec {})))
+
+(defn command-entry
+  [cmds command desc spec]
+  (let [spec* (merge-spec spec)]
+    {:cmds cmds
+     :command command
+     :desc desc
+     :spec spec*
+     :restrict true
+     :fn (fn [{:keys [opts args]}]
+           {:command command
+            :cmds cmds
+            :spec spec*
+            :opts opts
+            :args args})}))
+
+(defn- format-commands
+  [table]
+  (let [rows (->> table
+                  (filter (comp seq :cmds))
+                  (map (fn [{:keys [cmds desc spec]}]
+                         (let [command (str (string/join " " cmds)
+                                            (when (seq spec) " [options]"))]
+                           {:command command
+                            :desc desc}))))
+        width (apply max 0 (map (comp count :command) rows))]
+    (->> rows
+         (map (fn [{:keys [command desc]}]
+                (let [padding (apply str (repeat (- width (count command)) " "))]
+                  (cond-> (str "  " command padding)
+                    (seq desc) (str "  " desc)))))
+         (string/join "\n"))))
+
+(defn group-summary
+  [group table]
+  (let [group-table (filter #(= group (first (:cmds %))) table)]
+    (string/join "\n"
+                 [(str "Usage: logseq " group " <subcommand> [options]")
+                  ""
+                  "Subcommands:"
+                  (format-commands group-table)
+                  ""
+                  "Global options:"
+                  (cli/format-opts {:spec global-spec*})
+                  ""
+                  "Command options:"
+                  (str "  See `logseq " group " <subcommand> --help`")])))
+
+(defn top-level-summary
+  [table]
+  (let [groups [{:title "Graph Inspect and Edit"
+                 :commands #{"list" "add" "remove" "search" "show"}}
+                {:title "Graph Management"
+                 :commands #{"graph" "server"}}]
+        render-group (fn [{:keys [title commands]}]
+                       (let [entries (filter #(contains? commands (first (:cmds %))) table)]
+                         (string/join "\n" [title (format-commands entries)])))]
+    (string/join "\n"
+                 ["Usage: logseq <command> [options]"
+                  ""
+                  "Commands:"
+                  (string/join "\n\n" (map render-group groups))
+                  ""
+                  "Global options:"
+                  (cli/format-opts {:spec global-spec*})
+                  ""
+                  "Command options:"
+                  "  See `logseq <command> --help`"])))
+
+(defn command-summary
+  [{:keys [cmds spec]}]
+  (let [command-spec (apply dissoc spec (keys global-spec*))]
+    (string/join "\n"
+                 [(str "Usage: logseq " (string/join " " cmds) " [options]")
+                  ""
+                  "Global options:"
+                  (cli/format-opts {:spec global-spec*})
+                  ""
+                  "Command options:"
+                  (cli/format-opts {:spec command-spec})])))
+
+(defn normalize-opts
+  [opts]
+  (cond-> opts
+    (:config opts) (-> (assoc :config-path (:config opts))
+                       (dissoc :config))))
+
+(defn ok-result
+  [command opts args summary]
+  {:ok? true
+   :command command
+   :options (normalize-opts opts)
+   :args (vec args)
+   :summary summary})
+
+(defn help-result
+  [summary]
+  {:ok? false
+   :help? true
+   :summary summary})
+
+(defn invalid-options-result
+  [summary message]
+  {:ok? false
+   :error {:code :invalid-options
+           :message message}
+   :summary summary})
+
+(defn unknown-command-result
+  [summary message]
+  {:ok? false
+   :error {:code :unknown-command
+           :message message}
+   :summary summary})
+
+(def ^:private global-aliases
+  (->> global-spec*
+       (keep (fn [[k {:keys [alias]}]]
+               (when alias
+                 [alias k])))
+       (into {})))
+
+(def ^:private global-flag-options
+  (->> global-spec*
+       (keep (fn [[k {:keys [coerce]}]]
+               (when (= coerce :boolean) k)))
+       (set)))
+
+(defn- global-opt-key
+  [token]
+  (cond
+    (string/starts-with? token "--")
+    (keyword (subs token 2))
+
+    (and (string/starts-with? token "-")
+         (= 2 (count token)))
+    (get global-aliases (keyword (subs token 1)))
+
+    :else nil))
+
+(defn parse-leading-global-opts
+  [args]
+  (loop [remaining args
+         opts {}]
+    (if (empty? remaining)
+      {:opts opts :args []}
+      (let [token (first remaining)]
+        (if-let [opt-key (global-opt-key token)]
+          (if (contains? global-flag-options opt-key)
+            (recur (rest remaining) (assoc opts opt-key true))
+            (if-let [value (second remaining)]
+              (recur (drop 2 remaining) (assoc opts opt-key value))
+              {:opts opts :args (rest remaining)}))
+          {:opts opts :args remaining})))))
+
+(defn legacy-graph-opt?
+  [raw-args]
+  (some (fn [token]
+          (or (= token "--graph")
+              (string/starts-with? token "--graph=")))
+        raw-args))
+
+(defn cli-error->result
+  [summary {:keys [msg]}]
+  (invalid-options-result summary (or msg "invalid options")))
+
+(defn graph->repo
+  [graph]
+  (when (seq graph)
+    (if (string/starts-with? graph common-config/db-version-prefix)
+      graph
+      (str common-config/db-version-prefix graph))))
+
+(defn repo->graph
+  [repo]
+  (when (seq repo)
+    (string/replace-first repo common-config/db-version-prefix "")))
+
+(defn resolve-repo
+  [graph]
+  (let [graph (some-> graph string/trim)]
+    (when (seq graph)
+      (graph->repo graph))))
+
+(defn pick-graph
+  [options command-args config]
+  (or (:repo options)
+      (first command-args)
+      (:repo config)))

+ 226 - 0
src/main/logseq/cli/command/graph.cljs

@@ -0,0 +1,226 @@
+(ns logseq.cli.command.graph
+  "Graph-related CLI commands."
+  (:require [clojure.string :as string]
+            [logseq.cli.command.core :as core]
+            [logseq.cli.config :as cli-config]
+            [logseq.cli.server :as cli-server]
+            [logseq.cli.transport :as transport]
+            [promesa.core :as p]))
+
+(def ^:private graph-export-spec
+  {:type {:desc "Export type (edn, sqlite)"}
+   :output {:desc "Output path"}})
+
+(def ^:private graph-import-spec
+  {:type {:desc "Import type (edn, sqlite)"}
+   :input {:desc "Input path"}})
+
+(def entries
+  [(core/command-entry ["graph" "list"] :graph-list "List graphs" {})
+   (core/command-entry ["graph" "create"] :graph-create "Create graph" {})
+   (core/command-entry ["graph" "switch"] :graph-switch "Switch current graph" {})
+   (core/command-entry ["graph" "remove"] :graph-remove "Remove graph" {})
+   (core/command-entry ["graph" "validate"] :graph-validate "Validate graph" {})
+   (core/command-entry ["graph" "info"] :graph-info "Graph metadata" {})
+   (core/command-entry ["graph" "export"] :graph-export "Export graph" graph-export-spec)
+   (core/command-entry ["graph" "import"] :graph-import "Import graph" graph-import-spec)])
+
+(def ^:private import-export-types*
+  #{"edn" "sqlite"})
+
+(defn import-export-types
+  []
+  import-export-types*)
+
+(defn normalize-import-export-type
+  [value]
+  (some-> value string/lower-case string/trim))
+
+(defn- missing-graph-error
+  []
+  {:ok? false
+   :error {:code :missing-graph
+           :message "graph name is required"}})
+
+(defn build-graph-action
+  [command graph repo]
+  (case command
+    :graph-list
+    {:ok? true
+     :action {:type :graph-list
+              :command :graph-list}}
+
+    :graph-create
+    (if-not (seq graph)
+      (missing-graph-error)
+      {:ok? true
+       :action {:type :invoke
+                :command :graph-create
+                :method :thread-api/create-or-open-db
+                :direct-pass? false
+                :args [repo {}]
+                :repo repo
+                :graph (core/repo->graph repo)
+                :allow-missing-graph true
+                :persist-repo (core/repo->graph repo)}})
+
+    :graph-switch
+    (if-not (seq graph)
+      (missing-graph-error)
+      {:ok? true
+       :action {:type :graph-switch
+                :command :graph-switch
+                :repo repo
+                :graph (core/repo->graph repo)}})
+
+    :graph-remove
+    (if-not (seq graph)
+      (missing-graph-error)
+      {:ok? true
+       :action {:type :invoke
+                :command :graph-remove
+                :method :thread-api/unsafe-unlink-db
+                :direct-pass? false
+                :args [repo]
+                :repo repo
+                :graph (core/repo->graph repo)}})
+
+    :graph-validate
+    (if-not (seq repo)
+      (missing-graph-error)
+      {:ok? true
+       :action {:type :invoke
+                :command :graph-validate
+                :method :thread-api/validate-db
+                :direct-pass? false
+                :args [repo]
+                :repo repo
+                :graph (core/repo->graph repo)}})
+
+    :graph-info
+    (if-not (seq repo)
+      (missing-graph-error)
+      {:ok? true
+       :action {:type :graph-info
+                :command :graph-info
+                :repo repo
+                :graph (core/repo->graph repo)}})))
+
+(defn build-export-action
+  [repo export-type output]
+  (if-not (seq repo)
+    {:ok? false
+     :error {:code :missing-repo
+             :message "repo is required for export"}}
+    {:ok? true
+     :action {:type :graph-export
+              :repo repo
+              :graph (core/repo->graph repo)
+              :export-type export-type
+              :output output}}))
+
+(defn build-import-action
+  [repo import-type input]
+  (if-not (seq repo)
+    {:ok? false
+     :error {:code :missing-repo
+             :message "repo is required for import"}}
+    {:ok? true
+     :action {:type :graph-import
+              :repo repo
+              :graph (core/repo->graph repo)
+              :import-type import-type
+              :input input
+              :allow-missing-graph true}}))
+
+(defn execute-graph-list
+  [_action config]
+  (let [graphs (cli-server/list-graphs config)]
+    {:status :ok
+     :data {:graphs graphs}}))
+
+(defn execute-invoke
+  [action config]
+  (-> (p/let [cfg (if-let [repo (:repo action)]
+                    (cli-server/ensure-server! config repo)
+                    (p/resolved config))
+              result (transport/invoke cfg
+                                       (:method action)
+                                       (:direct-pass? action)
+                                       (:args action))]
+        (when-let [repo (:persist-repo action)]
+          (cli-config/update-config! config {:repo repo}))
+        (if-let [write (:write action)]
+          (let [{:keys [format path]} write]
+            (transport/write-output {:format format :path path :data result})
+            {:status :ok
+             :data {:message (str "wrote " path)}})
+          {:status :ok :data {:result result}}))))
+
+(defn execute-graph-switch
+  [action config]
+  (-> (p/let [graphs (cli-server/list-graphs config)
+              graph (:graph action)]
+        (if-not (some #(= graph %) graphs)
+          {:status :error
+           :error {:code :graph-not-found
+                   :message (str "graph not found: " graph)}}
+          (p/let [_ (cli-server/ensure-server! config (:repo action))]
+            (cli-config/update-config! config {:repo graph})
+            {:status :ok
+             :data {:message (str "switched to " graph)}})))))
+
+(defn execute-graph-info
+  [action config]
+  (-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
+              created (transport/invoke cfg :thread-api/pull false [(:repo action) [:kv/value] :logseq.kv/graph-created-at])
+              schema (transport/invoke cfg :thread-api/pull false [(:repo action) [:kv/value] :logseq.kv/schema-version])]
+        {:status :ok
+         :data {:graph (:graph action)
+                :logseq.kv/graph-created-at (:kv/value created)
+                :logseq.kv/schema-version (:kv/value schema)}})))
+
+(defn execute-graph-export
+  [action config]
+  (-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
+              export-type (:export-type action)
+              export-result (case export-type
+                              "edn"
+                              (transport/invoke cfg
+                                                :thread-api/export-edn
+                                                false
+                                                [(:repo action) {:export-type :graph}])
+                              "sqlite"
+                              (transport/invoke cfg
+                                                :thread-api/export-db-base64
+                                                true
+                                                [(:repo action)])
+                              (throw (ex-info "unsupported export type" {:export-type export-type})))
+              data (if (= export-type "sqlite")
+                     (js/Buffer.from export-result "base64")
+                     export-result)
+              format (if (= export-type "sqlite") :sqlite :edn)]
+        (transport/write-output {:format format :path (:output action) :data data})
+        {:status :ok
+         :data {:message (str "wrote " (:output action))}})))
+
+(defn execute-graph-import
+  [action config]
+  (-> (p/let [_ (cli-server/stop-server! config (:repo action))
+              cfg (cli-server/ensure-server! config (:repo action))
+              import-type (:import-type action)
+              input-data (case import-type
+                           "edn" (transport/read-input {:format :edn :path (:input action)})
+                           "sqlite" (transport/read-input {:format :sqlite :path (:input action)})
+                           (throw (ex-info "unsupported import type" {:import-type import-type})))
+              payload (if (= import-type "sqlite")
+                        (.toString (js/Buffer.from input-data) "base64")
+                        input-data)
+              method (if (= import-type "sqlite")
+                       :thread-api/import-db-base64
+                       :thread-api/import-edn)
+              direct-pass? (= import-type "sqlite")
+              _ (transport/invoke cfg method direct-pass? [(:repo action) payload])
+              _ (cli-server/restart-server! config (:repo action))]
+        {:status :ok
+         :data {:message (str "imported " import-type " from " (:input action))}})))

+ 212 - 0
src/main/logseq/cli/command/list.cljs

@@ -0,0 +1,212 @@
+(ns logseq.cli.command.list
+  "List-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]
+            [promesa.core :as p]))
+
+(def ^:private list-common-spec
+  {:expand {:desc "Include expanded metadata"
+            :coerce :boolean}
+   :limit {:desc "Limit results"
+           :coerce :long}
+   :offset {:desc "Offset results"
+            :coerce :long}
+   :sort {:desc "Sort field"}
+   :order {:desc "Sort order (asc, desc)"}})
+
+(def ^:private list-page-spec
+  (merge list-common-spec
+         {:include-journal {:desc "Include journal pages"
+                            :coerce :boolean}
+          :journal-only {:desc "Only journal pages"
+                         :coerce :boolean}
+          :include-hidden {:desc "Include hidden pages"
+                           :coerce :boolean}
+          :updated-after {:desc "Filter by updated-at (ISO8601)"}
+          :created-after {:desc "Filter by created-at (ISO8601)"}
+          :fields {:desc "Select output fields (comma separated)"}}))
+
+(def ^:private list-tag-spec
+  (merge list-common-spec
+         {:include-built-in {:desc "Include built-in tags"
+                             :coerce :boolean}
+          :with-properties {:desc "Include tag properties"
+                            :coerce :boolean}
+          :with-extends {:desc "Include tag extends"
+                         :coerce :boolean}
+          :fields {:desc "Select output fields (comma separated)"}}))
+
+(def ^:private list-property-spec
+  (merge list-common-spec
+         {:include-built-in {:desc "Include built-in properties"
+                             :coerce :boolean}
+          :with-classes {:desc "Include property classes"
+                         :coerce :boolean}
+          :with-type {:desc "Include property type"
+                      :coerce :boolean}
+          :fields {:desc "Select output fields (comma separated)"}}))
+
+(def entries
+  [(core/command-entry ["list" "page"] :list-page "List pages" list-page-spec)
+   (core/command-entry ["list" "tag"] :list-tag "List tags" list-tag-spec)
+   (core/command-entry ["list" "property"] :list-property "List properties" list-property-spec)])
+
+(def ^:private list-sort-fields
+  {:list-page #{"title" "created-at" "updated-at"}
+   :list-tag #{"name" "title"}
+   :list-property #{"name" "title"}})
+
+(defn invalid-options?
+  [command opts]
+  (let [{:keys [order include-journal journal-only]} opts
+        sort-field (:sort opts)
+        allowed (get list-sort-fields command)]
+    (cond
+      (and include-journal journal-only)
+      "include-journal and journal-only are mutually exclusive"
+
+      (and (seq sort-field) (not (contains? allowed sort-field)))
+      (str "invalid sort field: " sort-field)
+
+      (and (seq order) (not (#{"asc" "desc"} order)))
+      (str "invalid order: " order)
+
+      :else nil)))
+
+(defn build-action
+  [command options repo]
+  (if-not (seq repo)
+    {:ok? false
+     :error {:code :missing-repo
+             :message "repo is required for list"}}
+    {:ok? true
+     :action {:type command
+              :repo repo
+              :options options}}))
+
+(def ^:private list-page-field-map
+  {"title" :block/title
+   "uuid" :block/uuid
+   "created-at" :block/created-at
+   "updated-at" :block/updated-at})
+
+(def ^:private list-tag-field-map
+  {"name" :block/title
+   "title" :block/title
+   "uuid" :block/uuid
+   "properties" :logseq.property.class/properties
+   "extends" :logseq.property.class/extends
+   "description" :logseq.property/description})
+
+(def ^:private list-property-field-map
+  {"name" :block/title
+   "title" :block/title
+   "uuid" :block/uuid
+   "classes" :logseq.property/classes
+   "type" :logseq.property/type
+   "description" :logseq.property/description})
+
+(defn- parse-field-list
+  [fields]
+  (when (seq fields)
+    (->> (string/split fields #",")
+         (map string/trim)
+         (remove string/blank?)
+         vec)))
+
+(defn- apply-fields
+  [items fields field-map]
+  (if (seq fields)
+    (let [keys (->> fields
+                    (map #(get field-map %))
+                    (remove nil?)
+                    vec)]
+      (if (seq keys)
+        (mapv #(select-keys % keys) items)
+        items))
+    items))
+
+(defn- apply-sort
+  [items sort-field order field-map]
+  (if (seq sort-field)
+    (let [sort-key (get field-map sort-field)
+          sorted (if sort-key
+                   (sort-by #(get % sort-key) items)
+                   items)
+          sorted (if (= "desc" order) (reverse sorted) sorted)]
+      (vec sorted))
+    (vec items)))
+
+(defn- apply-offset-limit
+  [items offset limit]
+  (cond-> items
+    (some? offset) (->> (drop offset) vec)
+    (some? limit) (->> (take limit) vec)))
+
+(defn- prepare-tag-item
+  [item {:keys [expand with-properties with-extends]}]
+  (if expand
+    (cond-> item
+      (not with-properties) (dissoc :logseq.property.class/properties)
+      (not with-extends) (dissoc :logseq.property.class/extends))
+    item))
+
+(defn- prepare-property-item
+  [item {:keys [expand with-classes with-type]}]
+  (if expand
+    (cond-> item
+      (not with-classes) (dissoc :logseq.property/classes)
+      (not with-type) (dissoc :logseq.property/type))
+    item))
+
+(defn execute-list-page
+  [action config]
+  (-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
+              options (:options action)
+              items (transport/invoke cfg :thread-api/api-list-pages false
+                                      [(:repo action) options])
+              order (or (:order options) "asc")
+              fields (parse-field-list (:fields options))
+              sorted (apply-sort items (:sort options) order list-page-field-map)
+              limited (apply-offset-limit sorted (:offset options) (:limit options))
+              final (if (:expand options)
+                      (apply-fields limited fields list-page-field-map)
+                      limited)]
+        {:status :ok
+         :data {:items final}})))
+
+(defn execute-list-tag
+  [action config]
+  (-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
+              options (:options action)
+              items (transport/invoke cfg :thread-api/api-list-tags false
+                                      [(:repo action) options])
+              order (or (:order options) "asc")
+              fields (parse-field-list (:fields options))
+              prepared (mapv #(prepare-tag-item % options) items)
+              sorted (apply-sort prepared (:sort options) order list-tag-field-map)
+              limited (apply-offset-limit sorted (:offset options) (:limit options))
+              final (if (:expand options)
+                      (apply-fields limited fields list-tag-field-map)
+                      limited)]
+        {:status :ok
+         :data {:items final}})))
+
+(defn execute-list-property
+  [action config]
+  (-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
+              options (:options action)
+              items (transport/invoke cfg :thread-api/api-list-properties false
+                                      [(:repo action) options])
+              order (or (:order options) "asc")
+              fields (parse-field-list (:fields options))
+              prepared (mapv #(prepare-property-item % options) items)
+              sorted (apply-sort prepared (:sort options) order list-property-field-map)
+              limited (apply-offset-limit sorted (:offset options) (:limit options))
+              final (if (:expand options)
+                      (apply-fields limited fields list-property-field-map)
+                      limited)]
+        {:status :ok
+         :data {:items final}})))

+ 81 - 0
src/main/logseq/cli/command/remove.cljs

@@ -0,0 +1,81 @@
+(ns logseq.cli.command.remove
+  "Remove-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 remove-block-spec
+  {:block {:desc "Block UUID"}})
+
+(def ^:private remove-page-spec
+  {:page {:desc "Page name"}})
+
+(def entries
+  [(core/command-entry ["remove" "block"] :remove-block "Remove block" remove-block-spec)
+   (core/command-entry ["remove" "page"] :remove-page "Remove page" remove-page-spec)])
+
+(defn- perform-remove
+  [config {:keys [repo block page]}]
+  (cond
+    (seq block)
+    (if-not (common-util/uuid-string? block)
+      (p/rejected (ex-info "block must be a uuid" {:code :invalid-block}))
+      (p/let [entity (transport/invoke config :thread-api/pull false
+                                       [repo [:db/id :block/uuid] [:block/uuid (uuid block)]])]
+        (if-let [id (:db/id entity)]
+          (transport/invoke config :thread-api/apply-outliner-ops false
+                            [repo [[:delete-blocks [[id] {}]]] {}])
+          (throw (ex-info "block not found" {:code :block-not-found})))))
+
+    (seq page)
+    (p/let [entity (transport/invoke config :thread-api/pull false
+                                     [repo [:db/id :block/uuid] [:block/name page]])]
+      (if-let [page-uuid (:block/uuid entity)]
+        (transport/invoke config :thread-api/apply-outliner-ops false
+                          [repo [[:delete-page [page-uuid]]] {}])
+        (throw (ex-info "page not found" {:code :page-not-found}))))
+
+    :else
+    (p/rejected (ex-info "block or page required" {:code :missing-target}))))
+
+(defn build-remove-block-action
+  [options repo]
+  (if-not (seq repo)
+    {:ok? false
+     :error {:code :missing-repo
+             :message "repo is required for remove"}}
+    (let [block (some-> (:block options) string/trim)]
+      (if (seq block)
+        {:ok? true
+         :action {:type :remove-block
+                  :repo repo
+                  :block block}}
+        {:ok? false
+         :error {:code :missing-target
+                 :message "block is required"}}))))
+
+(defn build-remove-page-action
+  [options repo]
+  (if-not (seq repo)
+    {:ok? false
+     :error {:code :missing-repo
+             :message "repo is required for remove"}}
+    (let [page (some-> (:page options) string/trim)]
+      (if (seq page)
+        {:ok? true
+         :action {:type :remove-page
+                  :repo repo
+                  :page page}}
+        {:ok? false
+         :error {:code :missing-target
+                 :message "page is required"}}))))
+
+(defn execute-remove
+  [action config]
+  (-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
+              result (perform-remove cfg action)]
+        {:status :ok
+         :data {:result result}})))

+ 240 - 0
src/main/logseq/cli/command/search.cljs

@@ -0,0 +1,240 @@
+(ns logseq.cli.command.search
+  "Search-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]
+            [promesa.core :as p]))
+
+(def ^:private search-spec
+  {:text {:desc "Search text"}
+   :type {:desc "Search types (page, block, tag, property, all)"}
+   :tag {:desc "Restrict to a specific tag"}
+   :limit {:desc "Limit results"
+           :coerce :long}
+   :case-sensitive {:desc "Case sensitive search"
+                    :coerce :boolean}
+   :include-content {:desc "Search block content"
+                     :coerce :boolean}
+   :sort {:desc "Sort field (updated-at, created-at)"}
+   :order {:desc "Sort order (asc, desc)"}})
+
+(def entries
+  [(core/command-entry ["search"] :search "Search graph" search-spec)])
+
+(def ^:private search-types
+  #{"page" "block" "tag" "property" "all"})
+
+(defn invalid-options?
+  [opts]
+  (let [type (:type opts)
+        order (:order opts)
+        sort-field (:sort opts)]
+    (cond
+      (and (seq type) (not (contains? search-types type)))
+      (str "invalid type: " type)
+
+      (and (seq sort-field) (not (#{"updated-at" "created-at"} sort-field)))
+      (str "invalid sort field: " sort-field)
+
+      (and (seq order) (not (#{"asc" "desc"} order)))
+      (str "invalid order: " order)
+
+      :else
+      nil)))
+
+(defn build-action
+  [options args repo]
+  (if-not (seq repo)
+    {:ok? false
+     :error {:code :missing-repo
+             :message "repo is required for search"}}
+    (let [text (or (:text options) (string/join " " args))]
+      (if (seq text)
+        {:ok? true
+         :action {:type :search
+                  :repo repo
+                  :text text
+                  :search-type (:type options)
+                  :tag (:tag options)
+                  :limit (:limit options)
+                  :case-sensitive (:case-sensitive options)
+                  :include-content (:include-content options)
+                  :sort (:sort options)
+                  :order (:order options)}}
+        {:ok? false
+         :error {:code :missing-search-text
+                 :message "search text is required"}}))))
+
+(defn- query-pages
+  [cfg repo text case-sensitive?]
+  (let [query (if case-sensitive?
+                '[:find ?e ?title ?uuid ?updated ?created
+                  :in $ ?q
+                  :where
+                  [?e :block/name ?name]
+                  [?e :block/title ?title]
+                  [?e :block/uuid ?uuid]
+                  [(get-else $ ?e :block/updated-at 0) ?updated]
+                  [(get-else $ ?e :block/created-at 0) ?created]
+                  [(clojure.string/includes? ?title ?q)]]
+                '[:find ?e ?title ?uuid ?updated ?created
+                  :in $ ?q
+                  :where
+                  [?e :block/name ?name]
+                  [?e :block/title ?title]
+                  [?e :block/uuid ?uuid]
+                  [(get-else $ ?e :block/updated-at 0) ?updated]
+                  [(get-else $ ?e :block/created-at 0) ?created]
+                  [(clojure.string/includes? (clojure.string/lower-case ?title) ?q)]])
+        q* (if case-sensitive? text (string/lower-case text))]
+    (transport/invoke cfg :thread-api/q false [repo [query q*]])))
+
+#_{:clj-kondo/ignore [:aliased-namespace-symbol]}
+(defn- query-blocks
+  [cfg repo text case-sensitive? tag include-content?]
+  (let [has-tag? (seq tag)
+        content-attr (if include-content? :block/content :block/title)
+        query (cond
+                (and case-sensitive? has-tag?)
+                `[:find ?e ?value ?uuid ?updated ?created
+                  :in $ ?q ?tag-name
+                  :where
+                  [?tag :block/name ?tag-name]
+                  [?e :block/tags ?tag]
+                  [?e ~content-attr ?value]
+                  [(missing? $ ?e :block/name)]
+                  [?e :block/uuid ?uuid]
+                  [(get-else $ ?e :block/updated-at 0) ?updated]
+                  [(get-else $ ?e :block/created-at 0) ?created]
+                  [(clojure.string/includes? ?value ?q)]]
+
+                case-sensitive?
+                `[:find ?e ?value ?uuid ?updated ?created
+                  :in $ ?q
+                  :where
+                  [?e ~content-attr ?value]
+                  [(missing? $ ?e :block/name)]
+                  [?e :block/uuid ?uuid]
+                  [(get-else $ ?e :block/updated-at 0) ?updated]
+                  [(get-else $ ?e :block/created-at 0) ?created]
+                  [(clojure.string/includes? ?value ?q)]]
+
+                has-tag?
+                `[:find ?e ?value ?uuid ?updated ?created
+                  :in $ ?q ?tag-name
+                  :where
+                  [?tag :block/name ?tag-name]
+                  [?e :block/tags ?tag]
+                  [?e ~content-attr ?value]
+                  [(missing? $ ?e :block/name)]
+                  [?e :block/uuid ?uuid]
+                  [(get-else $ ?e :block/updated-at 0) ?updated]
+                  [(get-else $ ?e :block/created-at 0) ?created]
+                  [(clojure.string/includes? (clojure.string/lower-case ?value) ?q)]]
+
+                :else
+                `[:find ?e ?value ?uuid ?updated ?created
+                  :in $ ?q
+                  :where
+                  [?e ~content-attr ?value]
+                  [(missing? $ ?e :block/name)]
+                  [?e :block/uuid ?uuid]
+                  [(get-else $ ?e :block/updated-at 0) ?updated]
+                  [(get-else $ ?e :block/created-at 0) ?created]
+                  [(clojure.string/includes? (clojure.string/lower-case ?value) ?q)]])
+        q* (if case-sensitive? text (string/lower-case text))
+        tag-name (some-> tag string/lower-case)]
+    (if has-tag?
+      (transport/invoke cfg :thread-api/q false [repo [query q* tag-name]])
+      (transport/invoke cfg :thread-api/q false [repo [query q*]]))))
+
+(defn- normalize-search-types
+  [type]
+  (let [type (or type "all")]
+    (case type
+      "page" [:page]
+      "block" [:block]
+      "tag" [:tag]
+      "property" [:property]
+      [:page :block :tag :property])))
+
+(defn- search-sort-key
+  [item sort-field]
+  (case sort-field
+    "updated-at" (:updated-at item)
+    "created-at" (:created-at item)
+    nil))
+
+(defn execute-search
+  [action config]
+  (-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
+              types (normalize-search-types (:search-type action))
+              case-sensitive? (boolean (:case-sensitive action))
+              text (:text action)
+              tag (:tag action)
+              page-results (when (some #{:page} types)
+                             (p/let [rows (query-pages cfg (:repo action) text case-sensitive?)]
+                               (mapv (fn [[id title uuid updated created]]
+                                       {:type "page"
+                                        :db/id id
+                                        :title title
+                                        :uuid (str uuid)
+                                        :updated-at updated
+                                        :created-at created})
+                                     rows)))
+              include-content? (boolean (:include-content action))
+              block-results (when (some #{:block} types)
+                              (p/let [rows (query-blocks cfg (:repo action) text case-sensitive? tag include-content?)]
+                                (mapv (fn [[id content uuid updated created]]
+                                        {:type "block"
+                                         :db/id id
+                                         :content content
+                                         :uuid (str uuid)
+                                         :updated-at updated
+                                         :created-at created})
+                                      rows)))
+              tag-results (when (some #{:tag} types)
+                            (p/let [items (transport/invoke cfg :thread-api/api-list-tags false
+                                                            [(:repo action) {:expand true :include-built-in true}])
+                                    q* (if case-sensitive? text (string/lower-case text))]
+                              (->> items
+                                   (filter (fn [item]
+                                             (let [title (:block/title item)]
+                                               (if case-sensitive?
+                                                 (string/includes? title q*)
+                                                 (string/includes? (string/lower-case title) q*)))))
+                                   (mapv (fn [item]
+                                           {:type "tag"
+                                            :title (:block/title item)
+                                            :uuid (:block/uuid item)})))))
+              property-results (when (some #{:property} types)
+                                 (p/let [items (transport/invoke cfg :thread-api/api-list-properties false
+                                                                 [(:repo action) {:expand true :include-built-in true}])
+                                         q* (if case-sensitive? text (string/lower-case text))]
+                                   (->> items
+                                        (filter (fn [item]
+                                                  (let [title (:block/title item)]
+                                                    (if case-sensitive?
+                                                      (string/includes? title q*)
+                                                      (string/includes? (string/lower-case title) q*)))))
+                                        (mapv (fn [item]
+                                                {:type "property"
+                                                 :title (:block/title item)
+                                                 :uuid (:block/uuid item)})))))
+              results (->> (concat (or page-results [])
+                                   (or block-results [])
+                                   (or tag-results [])
+                                   (or property-results []))
+                           (distinct)
+                           vec)
+              sorted (if-let [sort-field (:sort action)]
+                       (let [order (or (:order action) "desc")]
+                         (->> results
+                              (sort-by #(search-sort-key % sort-field))
+                              (cond-> (= order "desc") reverse)
+                              vec))
+                       results)
+              limited (if (some? (:limit action)) (vec (take (:limit action) sorted)) sorted)]
+        {:status :ok
+         :data {:results limited}})))

+ 96 - 0
src/main/logseq/cli/command/server.cljs

@@ -0,0 +1,96 @@
+(ns logseq.cli.command.server
+  "Server-related CLI commands."
+  (:require [logseq.cli.command.core :as core]
+            [logseq.cli.server :as cli-server]
+            [promesa.core :as p]))
+
+(def ^:private server-spec
+  {:repo {:desc "Graph name"}})
+
+(def entries
+  [(core/command-entry ["server" "list"] :server-list "List db-worker-node servers" {})
+   (core/command-entry ["server" "status"] :server-status "Show server status for a graph" server-spec)
+   (core/command-entry ["server" "start"] :server-start "Start db-worker-node for a graph" server-spec)
+   (core/command-entry ["server" "stop"] :server-stop "Stop db-worker-node for a graph" server-spec)
+   (core/command-entry ["server" "restart"] :server-restart "Restart db-worker-node for a graph" server-spec)])
+
+(defn build-action
+  [command repo]
+  (case command
+    :server-list
+    {:ok? true
+     :action {:type :server-list}}
+
+    :server-status
+    (if-not (seq repo)
+      {:ok? false
+       :error {:code :missing-repo
+               :message "repo is required for server status"}}
+      {:ok? true
+       :action {:type :server-status
+                :repo repo}})
+
+    :server-start
+    (if-not (seq repo)
+      {:ok? false
+       :error {:code :missing-repo
+               :message "repo is required for server start"}}
+      {:ok? true
+       :action {:type :server-start
+                :repo repo}})
+
+    :server-stop
+    (if-not (seq repo)
+      {:ok? false
+       :error {:code :missing-repo
+               :message "repo is required for server stop"}}
+      {:ok? true
+       :action {:type :server-stop
+                :repo repo}})
+
+    :server-restart
+    (if-not (seq repo)
+      {:ok? false
+       :error {:code :missing-repo
+               :message "repo is required for server restart"}}
+      {:ok? true
+       :action {:type :server-restart
+                :repo repo}})
+
+    {:ok? false
+     :error {:code :unknown-command
+             :message (str "unknown server command: " command)}}))
+
+(defn- server-result->response
+  [result]
+  (if (:ok? result)
+    {:status :ok
+     :data (:data result)}
+    {:status :error
+     :error (:error result)}))
+
+(defn execute-list
+  [_action config]
+  (-> (p/let [servers (cli-server/list-servers config)]
+        {:status :ok
+         :data {:servers servers}})))
+
+(defn execute-status
+  [action config]
+  (-> (p/let [result (cli-server/server-status config (:repo action))]
+        (server-result->response result))))
+
+(defn execute-start
+  [action config]
+  (-> (p/let [result (cli-server/start-server! config (:repo action))]
+        (server-result->response result))))
+
+(defn execute-stop
+  [action config]
+  (-> (p/let [result (cli-server/stop-server! config (:repo action))]
+        (server-result->response result))))
+
+(defn execute-restart
+  [action config]
+  (-> (p/let [result (cli-server/restart-server! config (:repo action))]
+        (server-result->response result))))

+ 189 - 0
src/main/logseq/cli/command/show.cljs

@@ -0,0 +1,189 @@
+(ns logseq.cli.command.show
+  "Show-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 show-spec
+  {:id {:desc "Block db/id"
+        :coerce :long}
+   :uuid {:desc "Block UUID"}
+   :page-name {:desc "Page name"}
+   :level {:desc "Limit tree depth"
+           :coerce :long}
+   :format {:desc "Output format (text, json, edn)"}})
+
+(def entries
+  [(core/command-entry ["show"] :show "Show tree" show-spec)])
+
+(def ^:private show-formats
+  #{"text" "json" "edn"})
+
+(defn invalid-options?
+  [opts]
+  (let [format (:format opts)
+        level (:level opts)]
+    (cond
+      (and (seq format) (not (contains? show-formats (string/lower-case format))))
+      (str "invalid format: " format)
+
+      (and (some? level) (< level 1))
+      "level must be >= 1"
+
+      :else
+      nil)))
+
+(def ^:private tree-block-selector
+  [:db/id :block/uuid :block/title :block/order {:block/parent [:db/id]}])
+
+(defn- fetch-blocks-for-page
+  [config repo page-id]
+  (let [query [:find (list 'pull '?b tree-block-selector)
+               :in '$ '?page-id
+               :where ['?b :block/page '?page-id]]]
+    (p/let [rows (transport/invoke config :thread-api/q false [repo [query page-id]])]
+      (mapv first rows))))
+
+(defn- build-tree
+  [blocks root-id max-depth]
+  (let [parent->children (group-by #(get-in % [:block/parent :db/id]) blocks)
+        sort-children (fn [children]
+                        (vec (sort-by :block/order children)))
+        build (fn build [parent-id depth]
+                (mapv (fn [b]
+                        (let [children (build (:db/id b) (inc depth))]
+                          (cond-> b
+                            (seq children) (assoc :block/children children))))
+                      (if (and max-depth (>= depth max-depth))
+                        []
+                        (sort-children (get parent->children parent-id)))))]
+    (build root-id 1)))
+
+(defn- fetch-tree
+  [config {:keys [repo id page-name level] :as opts}]
+  (let [max-depth (or level 10)
+        uuid-str (:uuid opts)]
+    (cond
+      (some? id)
+      (p/let [entity (transport/invoke config :thread-api/pull false
+                                       [repo [:db/id :block/name :block/uuid :block/title {:block/page [:db/id :block/title]}] id])]
+        (if-let [page-id (get-in entity [:block/page :db/id])]
+          (p/let [blocks (fetch-blocks-for-page config repo page-id)
+                  children (build-tree blocks (:db/id entity) max-depth)]
+            {:root (assoc entity :block/children children)})
+          (if (:db/id entity)
+            (p/let [blocks (fetch-blocks-for-page config repo (:db/id entity))
+                    children (build-tree blocks (:db/id entity) max-depth)]
+              {:root (assoc entity :block/children children)})
+            (throw (ex-info "block not found" {:code :block-not-found})))))
+
+      (seq uuid-str)
+      (if-not (common-util/uuid-string? uuid-str)
+        (p/rejected (ex-info "block must be a uuid" {:code :invalid-block}))
+        (p/let [entity (transport/invoke config :thread-api/pull false
+                                         [repo [:db/id :block/name :block/uuid :block/title {:block/page [:db/id :block/title]}]
+                                          [:block/uuid (uuid uuid-str)]])
+                entity (if (:db/id entity)
+                         entity
+                         (transport/invoke config :thread-api/pull false
+                                           [repo [:db/id :block/name :block/uuid :block/title {:block/page [:db/id :block/title]}]
+                                            [:block/uuid uuid-str]]))]
+          (if-let [page-id (get-in entity [:block/page :db/id])]
+            (p/let [blocks (fetch-blocks-for-page config repo page-id)
+                    children (build-tree blocks (:db/id entity) max-depth)]
+              {:root (assoc entity :block/children children)})
+            (if (:db/id entity)
+              (p/let [blocks (fetch-blocks-for-page config repo (:db/id entity))
+                      children (build-tree blocks (:db/id entity) max-depth)]
+                {:root (assoc entity :block/children children)})
+              (throw (ex-info "block not found" {:code :block-not-found}))))))
+
+      (seq page-name)
+      (p/let [page-entity (transport/invoke config :thread-api/pull false
+                                            [repo [:db/id :block/uuid :block/title] [:block/name page-name]])]
+        (if-let [page-id (:db/id page-entity)]
+          (p/let [blocks (fetch-blocks-for-page config repo page-id)
+                  children (build-tree blocks page-id max-depth)]
+            {:root (assoc page-entity :block/children children)})
+          (throw (ex-info "page not found" {:code :page-not-found}))))
+
+      :else
+      (p/rejected (ex-info "block or page required" {:code :missing-target})))))
+
+(defn tree->text
+  [{:keys [root]}]
+  (let [label (fn [node]
+                (or (:block/title node) (:block/name node) (str (:block/uuid node))))
+        node-id (fn [node]
+                  (or (:db/id node) "-"))
+        id-padding (fn [node]
+                     (apply str (repeat (inc (count (str (node-id node)))) " ")))
+        split-lines (fn [value]
+                      (string/split (or value "") #"\n"))
+        lines (atom [])
+        walk (fn walk [node prefix]
+               (let [children (:block/children node)
+                     total (count children)]
+                 (doseq [[idx child] (map-indexed vector children)]
+                   (let [last-child? (= idx (dec total))
+                         branch (if last-child? "└── " "├── ")
+                         next-prefix (str prefix (if last-child? "    " "│   "))
+                         rows (split-lines (label child))
+                         first-row (first rows)
+                         rest-rows (rest rows)
+                         line (str (node-id child) " " prefix branch first-row)]
+                     (swap! lines conj line)
+                     (doseq [row rest-rows]
+                       (swap! lines conj (str (id-padding child) next-prefix row)))
+                     (walk child next-prefix)))))]
+    (let [rows (split-lines (label root))
+          first-row (first rows)
+          rest-rows (rest rows)]
+      (swap! lines conj (str (node-id root) " " first-row))
+      (doseq [row rest-rows]
+        (swap! lines conj (str (id-padding root) row))))
+    (walk root "")
+    (string/join "\n" @lines)))
+
+(defn build-action
+  [options repo]
+  (if-not (seq repo)
+    {:ok? false
+     :error {:code :missing-repo
+             :message "repo is required for show"}}
+    (let [format (some-> (:format options) string/lower-case)
+          targets (filter some? [(:id options) (:uuid options) (:page-name options)])]
+      (if (empty? targets)
+        {:ok? false
+         :error {:code :missing-target
+                 :message "block or page is required"}}
+        {:ok? true
+         :action {:type :show
+                  :repo repo
+                  :id (:id options)
+                  :uuid (:uuid options)
+                  :page-name (:page-name options)
+                  :level (:level options)
+                  :format format}}))))
+
+(defn execute-show
+  [action config]
+  (-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
+              tree-data (fetch-tree cfg action)
+              format (:format action)]
+        (case format
+          "edn"
+          {:status :ok
+           :data tree-data
+           :output-format :edn}
+
+          "json"
+          {:status :ok
+           :data tree-data
+           :output-format :json}
+
+          {:status :ok
+           :data {:message (tree->text tree-data)}}))))

Разница между файлами не показана из-за своего большого размера
+ 89 - 1337
src/main/logseq/cli/commands.cljs


+ 1 - 1
src/main/logseq/cli/format.cljs

@@ -68,7 +68,7 @@
 (defn- error-hint
   [{:keys [code]}]
   (case code
-    :missing-graph "Use --graph <name>"
+    :missing-graph "Use --repo <name>"
     :missing-repo "Use --repo <name>"
     :missing-content "Use --content or pass content as args"
     :missing-search-text "Provide search text or --text"

+ 7 - 2
src/main/logseq/cli/transport.cljs

@@ -90,11 +90,16 @@
   [{:keys [base-url auth-token timeout-ms retries]}
    method direct-pass? args]
   (let [url (str (string/replace base-url #"/$" "") "/v1/invoke")
+        method* (cond
+                  (keyword? method) (subs (str method) 1)
+                  (string? method) method
+                  (nil? method) nil
+                  :else (str method))
         payload (if direct-pass?
-                  {:method method
+                  {:method method*
                    :directPass true
                    :args args}
-                  {:method method
+                  {:method method*
                    :directPass false
                    :argsTransit (ldb/write-transit-str args)})
         body (js/JSON.stringify (clj->js payload))]

+ 16 - 0
src/test/frontend/worker/db_worker_node_test.cljs

@@ -180,6 +180,22 @@
     (is (= "logseq_db_parse_args" (:repo result)))
     (is (= "/tmp/db-worker" (:data-dir result)))))
 
+(deftest db-worker-node-repo-error-handles-keyword-methods
+  (let [repo-error #'db-worker-node/repo-error
+        bound-repo "logseq_db_bound"]
+    (is (nil? (repo-error :thread-api/list-db [] bound-repo)))
+    (is (nil? (repo-error "thread-api/list-db" [] bound-repo)))
+    (is (= {:status 400
+            :error {:code :missing-repo
+                    :message "repo is required"}}
+           (repo-error :thread-api/create-or-open-db [] bound-repo)))
+    (is (= {:status 409
+            :error {:code :repo-mismatch
+                    :message "repo does not match bound repo"
+                    :repo "other"
+                    :bound-repo bound-repo}}
+           (repo-error :thread-api/create-or-open-db ["other"] bound-repo)))))
+
 (deftest db-worker-node-daemon-smoke-test
   (async done
     (let [daemon (atom nil)

+ 22 - 11
src/test/logseq/cli/commands_test.cljs

@@ -1,6 +1,7 @@
 (ns logseq.cli.commands-test
   (:require [cljs.test :refer [async deftest is testing]]
             [clojure.string :as string]
+            [logseq.cli.command.show :as show-command]
             [logseq.cli.commands :as commands]
             [logseq.cli.server :as cli-server]
             [logseq.cli.transport :as transport]
@@ -127,6 +128,13 @@
       (is (false? (:ok? result)))
       (is (= :unknown-command (get-in result [:error :code]))))))
 
+(deftest test-parse-args-rejects-graph-option
+  (testing "rejects legacy --graph option"
+    (let [result (commands/parse-args ["--graph" "demo" "graph" "list"])]
+      (is (false? (:ok? result)))
+      (is (= :invalid-options (get-in result [:error :code])))
+      (is (= "unknown option: --graph" (get-in result [:error :message]))))))
+
 (deftest test-parse-args-global-options
   (testing "global output option is accepted"
     (let [result (commands/parse-args ["--output" "json" "graph" "list"])]
@@ -135,7 +143,7 @@
 
 (deftest test-tree->text-format
   (testing "show tree text uses db/id with tree glyphs"
-    (let [tree->text #'commands/tree->text
+    (let [tree->text #'show-command/tree->text
           tree-data {:root {:db/id 1
                             :block/title "Root"
                             :block/children [{:db/id 2
@@ -152,7 +160,7 @@
 
 (deftest test-tree->text-multiline
   (testing "show tree text renders multiline blocks under glyph column"
-    (let [tree->text #'commands/tree->text
+    (let [tree->text #'show-command/tree->text
           tree-data {:root {:db/id 168
                             :block/title "Jan 18th, 2026"
                             :block/children [{:db/id 169
@@ -244,7 +252,7 @@
       (is (false? (:ok? result)))
       (is (= :invalid-options (get-in result [:error :code]))))))
 
-(deftest test-verb-subcommand-parse
+(deftest test-verb-subcommand-parse-add-remove
   (testing "add block requires content source"
     (let [result (commands/parse-args ["add" "block"])]
       (is (false? (:ok? result)))
@@ -282,8 +290,9 @@
     (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]))))))
 
+(deftest test-verb-subcommand-parse-search-show
   (testing "search requires text"
     (let [result (commands/parse-args ["search"])]
       (is (false? (:ok? result)))
@@ -304,8 +313,9 @@
     (let [result (commands/parse-args ["show" "--page-name" "Home"])]
       (is (true? (:ok? result)))
       (is (= :show (:command result)))
-      (is (= "Home" (get-in result [:options :page-name])))))
+      (is (= "Home" (get-in result [:options :page-name]))))))
 
+(deftest test-verb-subcommand-parse-graph-import-export
   (testing "graph export parses with type and output"
     (let [result (commands/parse-args ["graph" "export"
                                        "--type" "edn"
@@ -349,8 +359,9 @@
                                        "--input" "import.zip"
                                        "--repo" "demo"])]
       (is (false? (:ok? result)))
-      (is (= :invalid-options (get-in result [:error :code])))))
+      (is (= :invalid-options (get-in result [:error :code]))))))
 
+(deftest test-verb-subcommand-parse-flags
   (testing "verb subcommands reject unknown flags"
     (doseq [args [["list" "page" "--wat"]
                   ["add" "block" "--wat"]
@@ -517,7 +528,7 @@
                                         (assoc config :base-url "http://127.0.0.1:9999")))
       (set! transport/invoke (fn [_ method direct-pass? args]
                                (swap! invoke-calls conj [method direct-pass? args])
-                               (if (= method "thread-api/export-db-base64")
+                               (if (= method :thread-api/export-db-base64)
                                  "c3FsaXRl"
                                  {:exported true})))
       (set! transport/write-output (fn [opts]
@@ -538,8 +549,8 @@
                                                   {})]
             (is (= :ok (:status edn-result)))
             (is (= :ok (:status sqlite-result)))
-            (is (= [["thread-api/export-edn" false ["logseq_db_demo" {:export-type :graph}]]
-                    ["thread-api/export-db-base64" true ["logseq_db_demo"]]]
+            (is (= [[:thread-api/export-edn false ["logseq_db_demo" {:export-type :graph}]]
+                    [:thread-api/export-db-base64 true ["logseq_db_demo"]]]
                    @invoke-calls))
             (is (= 2 (count @write-calls)))
             (let [[edn-write sqlite-write] @write-calls]
@@ -605,8 +616,8 @@
             (is (= [[:edn "/tmp/import.edn"]
                     [:sqlite "/tmp/import.sqlite"]]
                    @read-calls))
-            (is (= [["thread-api/import-edn" ["logseq_db_demo" {:page "Import Page"}]]
-                    ["thread-api/import-db-base64" ["logseq_db_demo" "c3FsaXRl"]]]
+            (is (= [[:thread-api/import-edn ["logseq_db_demo" {:page "Import Page"}]]
+                    [:thread-api/import-db-base64 ["logseq_db_demo" "c3FsaXRl"]]]
                    @invoke-calls))
             (is (= ["logseq_db_demo" "logseq_db_demo"] @stop-calls))
             (is (= ["logseq_db_demo" "logseq_db_demo"] @restart-calls)))

+ 1 - 1
src/test/logseq/cli/format_test.cljs

@@ -193,5 +193,5 @@
                                                 :message "graph name is required"}}
                                        {:output-format nil})]
       (is (= (str "Error (missing-graph): graph name is required\n"
-                  "Hint: Use --graph <name>")
+                  "Hint: Use --repo <name>")
              result)))))

+ 32 - 9
src/test/logseq/cli/transport_test.cljs

@@ -72,23 +72,46 @@
                    (is false (str "unexpected error: " e))
                    (done))))))
 
+(deftest test-invoke-accepts-keyword-method
+  (async done
+    (let [received (atom nil)]
+      (-> (p/let [{:keys [url stop!]} (start-server
+                                       (fn [^js req ^js res]
+                                         (let [chunks (array)]
+                                           (.on req "data" (fn [chunk] (.push chunks chunk)))
+                                           (.on req "end" (fn []
+                                                            (let [buf (js/Buffer.concat chunks)
+                                                                  payload (js/JSON.parse (.toString buf "utf8"))]
+                                                              (reset! received (js->clj payload :keywordize-keys true))
+                                                              (.writeHead res 200 #js {"Content-Type" "application/json"})
+                                                              (.end res (js/JSON.stringify #js {:result "ok"}))))))))
+                 result (transport/invoke {:base-url url} :thread-api/pull true ["repo" [:block/title]])]
+            (is (= "ok" result))
+            (is (= "thread-api/pull" (:method @received)))
+            (is (= true (:directPass @received)))
+            (p/let [_ (stop!)]
+              (done)))
+          (p/catch (fn [e]
+                     (is false (str "unexpected error: " e))
+                     (done)))))))
+
 (deftest test-read-input
   (testing "reads edn input"
-    (let [path (temp-path "input.edn")]
-      (.writeFileSync fs path "{:a 1}")
-      (is (= {:a 1} (transport/read-input {:format :edn :path path})))))
+    (let [file-path (temp-path "input.edn")]
+      (.writeFileSync fs file-path "{:a 1}")
+      (is (= {:a 1} (transport/read-input {:format :edn :path file-path})))))
 
   (testing "reads sqlite input as buffer"
-    (let [path (temp-path "input.sqlite")
+    (let [file-path (temp-path "input.sqlite")
           buffer (js/Buffer.from "sqlite-data")]
-      (.writeFileSync fs path buffer)
-      (let [result (transport/read-input {:format :sqlite :path path})]
+      (.writeFileSync fs file-path buffer)
+      (let [result (transport/read-input {:format :sqlite :path file-path})]
         (is (instance? js/Buffer result))
         (is (= "sqlite-data" (.toString result "utf8")))))))
 
 (deftest test-write-output
   (testing "writes sqlite output as buffer"
-    (let [path (temp-path "output.sqlite")
+    (let [file-path (temp-path "output.sqlite")
           buffer (js/Buffer.from "sqlite-export")]
-      (transport/write-output {:format :sqlite :path path :data buffer})
-      (is (= "sqlite-export" (.toString (.readFileSync fs path) "utf8"))))))
+      (transport/write-output {:format :sqlite :path file-path :data buffer})
+      (is (= "sqlite-export" (.toString (.readFileSync fs file-path) "utf8"))))))

+ 0 - 93
tmp_scripts/db-worker-smoke-test.clj

@@ -1,93 +0,0 @@
-(require '[babashka.curl :as curl]
-         '[cheshire.core :as json]
-         '[cognitect.transit :as transit]
-         '[clojure.pprint :as pprint]
-         '[clojure.string :as string])
-
-(def base-url (or (System/getenv "DB_WORKER_URL") "http://127.0.0.1:9101"))
-
-(defn write-transit [v]
-  (let [out (java.io.ByteArrayOutputStream.)
-        w (transit/writer out :json)]
-    (transit/write w v)
-    (.toString out "UTF-8")))
-
-(defn read-transit [s]
-  (let [in (java.io.ByteArrayInputStream. (.getBytes s "UTF-8"))
-        r (transit/reader in :json)]
-    (transit/read r)))
-
-(defn invoke [method direct-pass? args]
-  (let [payload (if direct-pass?
-                  {:method method :directPass true :args args}
-                  {:method method :directPass false :argsTransit (write-transit args)})
-        resp (curl/post (str base-url "/v1/invoke")
-                        {:headers {"Content-Type" "application/json"}
-                         :body (json/generate-string payload)})
-        body (json/parse-string (:body resp) true)]
-    (if (<= 200 (:status resp) 299)
-      (if direct-pass?
-        (:result body)
-        (read-transit (:resultTransit body)))
-      (throw (ex-info "db-worker invoke failed" {:status (:status resp) :body (:body resp)})))))
-
-(def suffix (subs (str (random-uuid)) 0 8))
-(def repo (str "logseq_db_smoke_" suffix))
-(def page-uuid (random-uuid))
-(def block-uuid (random-uuid))
-(def now (long (System/currentTimeMillis)))
-
-(println "== db-worker-node smoke test ==")
-(println "Base URL:" base-url)
-(println "Repo:" repo)
-(println "Step 1/4: list-db (before)")
-(println "Result:" (json/generate-string (invoke "thread-api/list-db" false [])
-                                         {:pretty true}))
-
-(println "Step 2/4: create-or-open-db")
-(invoke "thread-api/create-or-open-db" false [repo {}])
-(println "Step 3/4: list-db (after)")
-(println "Result:" (json/generate-string (invoke "thread-api/list-db" false [])
-                                         {:pretty true}))
-
-(println "Step 4/4: transact + q")
-(invoke "thread-api/transact" false
-        [repo
-         [{:block/uuid page-uuid
-           :block/title "Smoke Page"
-           :block/name "smoke-page"
-           :block/tags #{:logseq.class/Page}
-           :block/created-at now
-           :block/updated-at now}
-          {:block/uuid block-uuid
-           :block/title "Smoke Test"
-           :block/page [:block/uuid page-uuid]
-           :block/parent [:block/uuid page-uuid]
-           :block/order "a0"
-           :block/created-at now
-           :block/updated-at now}]
-         {}
-         nil])
-
-(let [query '[:find ?e
-              :in $ ?uuid
-              :where [?e :block/uuid ?uuid]]
-      result (invoke "thread-api/q" false [repo [query block-uuid]])]
-  (println "Query result:" result)
-  (when (empty? result)
-    (throw (ex-info "Query returned no results" {:uuid block-uuid}))))
-
-(let [page-query '[:find (pull ?e [:db/id :block/uuid :block/title :block/name :block/tags])
-                   :in $ ?uuid
-                   :where [?e :block/uuid ?uuid]]
-      blocks-query '[:find (pull ?e [:db/id :block/uuid :block/title :block/order :block/parent])
-                     :in $ ?page-uuid
-                     :where [?page :block/uuid ?page-uuid]
-                            [?e :block/page ?page]]
-      page-result (invoke "thread-api/q" false [repo [page-query page-uuid]])
-      blocks-result (invoke "thread-api/q" false [repo [blocks-query page-uuid]])]
-  (println "Page + blocks (pretty):")
-  (pprint/pprint {:page page-result
-                  :blocks blocks-result}))
-
-(println "Smoke test OK")

+ 0 - 53
tmp_scripts/db-worker-sse-smoke-test.clj

@@ -1,53 +0,0 @@
-#!/usr/bin/env bb
-(require '[babashka.process :as process]
-         '[clojure.java.io :as io]
-         '[clojure.string :as string])
-
-(def base-url (or (System/getenv "DB_WORKER_URL")
-                  "http://127.0.0.1:9101"))
-(def auth-token (System/getenv "DB_WORKER_AUTH_TOKEN"))
-(def events-url (str (string/replace base-url #"/$" "") "/v1/events"))
-
-(defn- open-sse-connection
-  [url token]
-  (let [^java.net.HttpURLConnection conn (.openConnection (java.net.URL. url))]
-    (.setRequestMethod conn "GET")
-    (.setRequestProperty conn "Accept" "text/event-stream")
-    (when (seq token)
-      (.setRequestProperty conn "Authorization" (str "Bearer " token)))
-    (.setDoInput conn true)
-    (.connect conn)
-    conn))
-
-(defn- wait-for-sse!
-  [^java.net.HttpURLConnection conn timeout-ms]
-  (let [event-seen (promise)
-        reader (future
-                 (try
-                   (with-open [rdr (io/reader (.getInputStream conn))]
-                     (doseq [line (line-seq rdr)]
-                       (when (string/starts-with? line "data:")
-                         (deliver event-seen line)
-                         (reduced nil))))
-                   (catch Exception _ nil)))]
-    (try
-      (let [result (deref event-seen timeout-ms ::timeout)]
-        (when (= result ::timeout)
-          (throw (ex-info "No SSE events captured" {:url events-url})))
-        result)
-      (finally
-        (.disconnect conn)
-        (future-cancel reader)))))
-
-(defn- run-smoke-test!
-  []
-  (let [{:keys [exit]} (process/shell {:inherit true}
-                                      "bb" "tmp_scripts/db-worker-smoke-test.clj")]
-    (when-not (zero? exit)
-      (throw (ex-info "Smoke test failed" {:exit exit})))))
-
-(comment
-  (let [conn (open-sse-connection events-url auth-token)]
-    (run-smoke-test!)
-    (wait-for-sse! conn 2000)
-    (println "SSE smoke test OK")))

Некоторые файлы не были показаны из-за большого количества измененных файлов