From 4b7946c3b8a2e9decd292024c1cebababfc5a4b2 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Mon, 19 Jan 2026 23:43:20 +0800 Subject: [PATCH] impl 007-logseq-cli-thread-api-and-command-split.md --- src/main/frontend/worker/db_worker_node.cljs | 75 +- src/main/logseq/cli/command/add.cljs | 147 ++ src/main/logseq/cli/command/core.cljs | 216 +++ src/main/logseq/cli/command/graph.cljs | 226 +++ src/main/logseq/cli/command/list.cljs | 212 +++ src/main/logseq/cli/command/remove.cljs | 81 + src/main/logseq/cli/command/search.cljs | 240 +++ src/main/logseq/cli/command/server.cljs | 96 ++ src/main/logseq/cli/command/show.cljs | 189 +++ src/main/logseq/cli/commands.cljs | 1450 ++--------------- src/main/logseq/cli/format.cljs | 2 +- src/main/logseq/cli/transport.cljs | 9 +- .../frontend/worker/db_worker_node_test.cljs | 16 + src/test/logseq/cli/commands_test.cljs | 33 +- src/test/logseq/cli/format_test.cljs | 2 +- src/test/logseq/cli/transport_test.cljs | 41 +- tmp_scripts/db-worker-smoke-test.clj | 93 -- tmp_scripts/db-worker-sse-smoke-test.clj | 53 - 18 files changed, 1636 insertions(+), 1545 deletions(-) create mode 100644 src/main/logseq/cli/command/add.cljs create mode 100644 src/main/logseq/cli/command/core.cljs create mode 100644 src/main/logseq/cli/command/graph.cljs create mode 100644 src/main/logseq/cli/command/list.cljs create mode 100644 src/main/logseq/cli/command/remove.cljs create mode 100644 src/main/logseq/cli/command/search.cljs create mode 100644 src/main/logseq/cli/command/server.cljs create mode 100644 src/main/logseq/cli/command/show.cljs delete mode 100644 tmp_scripts/db-worker-smoke-test.clj delete mode 100644 tmp_scripts/db-worker-sse-smoke-test.clj diff --git a/src/main/frontend/worker/db_worker_node.cljs b/src/main/frontend/worker/db_worker_node.cljs index 7ef7b14b10..368b5fe188 100644 --- a/src/main/frontend/worker/db_worker_node.cljs +++ b/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- (.remoteInvoke proxy method (boolean direct-pass?) args') + (-> (.remoteInvoke proxy method-str (boolean direct-pass?) args') (p/finally (fn [] (js/clearTimeout timeout-id)))))) (defn- (p/let [body (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 (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}}))) diff --git a/src/main/logseq/cli/command/core.cljs b/src/main/logseq/cli/command/core.cljs new file mode 100644 index 0000000000..7fa0d7f1c6 --- /dev/null +++ b/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 " [options]") + "" + "Subcommands:" + (format-commands group-table) + "" + "Global options:" + (cli/format-opts {:spec global-spec*}) + "" + "Command options:" + (str " See `logseq " group " --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 [options]" + "" + "Commands:" + (string/join "\n\n" (map render-group groups)) + "" + "Global options:" + (cli/format-opts {:spec global-spec*}) + "" + "Command options:" + " See `logseq --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))) diff --git a/src/main/logseq/cli/command/graph.cljs b/src/main/logseq/cli/command/graph.cljs new file mode 100644 index 0000000000..8a23a08848 --- /dev/null +++ b/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))}}))) diff --git a/src/main/logseq/cli/command/list.cljs b/src/main/logseq/cli/command/list.cljs new file mode 100644 index 0000000000..6053e7e167 --- /dev/null +++ b/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}}))) diff --git a/src/main/logseq/cli/command/remove.cljs b/src/main/logseq/cli/command/remove.cljs new file mode 100644 index 0000000000..c2ec7df163 --- /dev/null +++ b/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}}))) diff --git a/src/main/logseq/cli/command/search.cljs b/src/main/logseq/cli/command/search.cljs new file mode 100644 index 0000000000..074422324a --- /dev/null +++ b/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}}))) diff --git a/src/main/logseq/cli/command/server.cljs b/src/main/logseq/cli/command/server.cljs new file mode 100644 index 0000000000..2d37477988 --- /dev/null +++ b/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)))) diff --git a/src/main/logseq/cli/command/show.cljs b/src/main/logseq/cli/command/show.cljs new file mode 100644 index 0000000000..85ed029851 --- /dev/null +++ b/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)}})))) diff --git a/src/main/logseq/cli/commands.cljs b/src/main/logseq/cli/commands.cljs index 2f61fae6ff..bff5d14b9a 100644 --- a/src/main/logseq/cli/commands.cljs +++ b/src/main/logseq/cli/commands.cljs @@ -1,205 +1,22 @@ (ns logseq.cli.commands "Command parsing and action building for the Logseq CLI." - (:require ["fs" :as fs] - [babashka.cli :as cli] - [cljs-time.coerce :as tc] - [cljs.reader :as reader] + (:require [babashka.cli :as cli] [clojure.string :as string] - [logseq.cli.config :as cli-config] + [logseq.cli.command.add :as add-command] + [logseq.cli.command.core :as command-core] + [logseq.cli.command.graph :as graph-command] + [logseq.cli.command.list :as list-command] + [logseq.cli.command.remove :as remove-command] + [logseq.cli.command.search :as search-command] + [logseq.cli.command.server :as server-command] + [logseq.cli.command.show :as show-command] [logseq.cli.server :as cli-server] - [logseq.cli.transport :as transport] - [logseq.common.config :as common-config] - [logseq.common.util :as common-util] - [logseq.common.util.date-time :as date-time-util] [promesa.core :as p])) (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)"}}) + (command-core/global-spec)) -(def ^:private server-spec - {:repo {:desc "Graph name"}}) - -(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 ^:private remove-block-spec - {:block {:desc "Block UUID"}}) - -(def ^:private remove-page-spec - {:page {:desc "Page name"}}) - -(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 ^: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 ^: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 ^: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"}}) - -(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 " [options]") - "" - "Subcommands:" - (format-commands group-table) - "" - "Global options:" - (cli/format-opts {:spec global-spec}) - "" - "Command options:" - (str " See `logseq " group " --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 [options]" - "" - "Commands:" - (string/join "\n\n" (map render-group groups)) - "" - "Global options:" - (cli/format-opts {:spec global-spec}) - "" - "Command options:" - " See `logseq --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- merge-spec - [spec] - (merge global-spec (or 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}) +;; Parsing helpers and summaries are in logseq.cli.command.core. (defn- missing-graph-result [summary] @@ -264,171 +81,20 @@ :message "search text is required"} :summary summary}) -(defn- help-result - [summary] - {:ok? false - :help? true - :summary summary}) +;; Error helpers are in logseq.cli.command.core. -(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 list-sort-fields - {:list-page #{"title" "created-at" "updated-at"} - :list-tag #{"name" "title"} - :list-property #{"name" "title"}}) - -(def ^:private show-formats - #{"text" "json" "edn"}) - -(def ^:private search-types - #{"page" "block" "tag" "property" "all"}) - -(def ^:private import-export-types - #{"edn" "sqlite"}) - -(defn- normalize-import-export-type - [value] - (some-> value string/lower-case string/trim)) - -(defn- invalid-list-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- invalid-show-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))) - -(defn- invalid-search-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- command-entry - [cmds command desc spec] - (let [spec* (merge-spec spec)] - {:cmds cmds - :desc desc - :spec spec* - :restrict true - :fn (fn [{:keys [opts args]}] - {:command command - :cmds cmds - :spec spec* - :opts opts - :args args})})) +;; Command-specific validation and entries are in subcommand namespaces. (def ^:private table - [(command-entry ["graph" "list"] :graph-list "List graphs" {}) - (command-entry ["graph" "create"] :graph-create "Create graph" {}) - (command-entry ["graph" "switch"] :graph-switch "Switch current graph" {}) - (command-entry ["graph" "remove"] :graph-remove "Remove graph" {}) - (command-entry ["graph" "validate"] :graph-validate "Validate graph" {}) - (command-entry ["graph" "info"] :graph-info "Graph metadata" {}) - (command-entry ["graph" "export"] :graph-export "Export graph" graph-export-spec) - (command-entry ["graph" "import"] :graph-import "Import graph" graph-import-spec) - (command-entry ["server" "list"] :server-list "List db-worker-node servers" {}) - (command-entry ["server" "status"] :server-status "Show server status for a graph" server-spec) - (command-entry ["server" "start"] :server-start "Start db-worker-node for a graph" server-spec) - (command-entry ["server" "stop"] :server-stop "Stop db-worker-node for a graph" server-spec) - (command-entry ["server" "restart"] :server-restart "Restart db-worker-node for a graph" server-spec) - (command-entry ["list" "page"] :list-page "List pages" list-page-spec) - (command-entry ["list" "tag"] :list-tag "List tags" list-tag-spec) - (command-entry ["list" "property"] :list-property "List properties" list-property-spec) - (command-entry ["add" "block"] :add-block "Add blocks" content-add-spec) - (command-entry ["add" "page"] :add-page "Create page" add-page-spec) - (command-entry ["remove" "block"] :remove-block "Remove block" remove-block-spec) - (command-entry ["remove" "page"] :remove-page "Remove page" remove-page-spec) - (command-entry ["search"] :search "Search graph" search-spec) - (command-entry ["show"] :show "Show tree" show-spec)]) + (vec (concat graph-command/entries + server-command/entries + list-command/entries + add-command/entries + remove-command/entries + search-command/entries + show-command/entries))) -(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}))))) +;; Global option parsing lives in logseq.cli.command.core. (defn- unknown-command-message [{:keys [dispatch wrong-input]}] @@ -437,9 +103,9 @@ (defn- finalize-command [summary {:keys [command opts args cmds spec]}] - (let [opts (normalize-opts opts) + (let [opts (command-core/normalize-opts opts) args (vec args) - cmd-summary (command-summary {:cmds cmds :spec spec}) + cmd-summary (command-core/command-summary {:cmds cmds :spec spec}) graph (:repo opts) has-args? (seq args) has-content? (or (seq (:content opts)) @@ -449,7 +115,7 @@ show-targets (filter some? [(:id opts) (:uuid opts) (:page-name opts)])] (cond (:help opts) - (help-result cmd-summary) + (command-core/help-result cmd-summary) (and (#{:graph-create :graph-switch :graph-remove :graph-validate} command) (not (seq graph))) @@ -471,32 +137,33 @@ (missing-target-result summary) (and (= command :show) (> (count show-targets) 1)) - (invalid-options-result summary "only one of --id, --uuid, or --page-name is allowed") + (command-core/invalid-options-result summary "only one of --id, --uuid, or --page-name is allowed") (and (= command :search) (not (or (seq (:text opts)) has-args?))) (missing-search-result summary) (and (#{:list-page :list-tag :list-property} command) - (invalid-list-options? command opts)) - (invalid-options-result summary (invalid-list-options? command opts)) + (list-command/invalid-options? command opts)) + (command-core/invalid-options-result summary (list-command/invalid-options? command opts)) - (and (= command :show) (invalid-show-options? opts)) - (invalid-options-result summary (invalid-show-options? opts)) + (and (= command :show) (show-command/invalid-options? opts)) + (command-core/invalid-options-result summary (show-command/invalid-options? opts)) - (and (= command :search) (invalid-search-options? opts)) - (invalid-options-result summary (invalid-search-options? opts)) + (and (= command :search) (search-command/invalid-options? opts)) + (command-core/invalid-options-result summary (search-command/invalid-options? opts)) - (and (= command :graph-export) (not (seq (normalize-import-export-type (:type opts))))) + (and (= command :graph-export) (not (seq (graph-command/normalize-import-export-type (:type opts))))) (missing-type-result summary) (and (= command :graph-export) (not (seq (:output opts)))) (missing-output-result summary) (and (= command :graph-export) - (not (contains? import-export-types (normalize-import-export-type (:type opts))))) - (invalid-options-result summary (str "invalid type: " (:type opts))) + (not (contains? (graph-command/import-export-types) + (graph-command/normalize-import-export-type (:type opts))))) + (command-core/invalid-options-result summary (str "invalid type: " (:type opts))) - (and (= command :graph-import) (not (seq (normalize-import-export-type (:type opts))))) + (and (= command :graph-import) (not (seq (graph-command/normalize-import-export-type (:type opts))))) (missing-type-result summary) (and (= command :graph-import) (not (seq (:input opts)))) @@ -506,75 +173,67 @@ (missing-repo-result summary) (and (= command :graph-import) - (not (contains? import-export-types (normalize-import-export-type (:type opts))))) - (invalid-options-result summary (str "invalid type: " (:type opts))) + (not (contains? (graph-command/import-export-types) + (graph-command/normalize-import-export-type (:type opts))))) + (command-core/invalid-options-result summary (str "invalid type: " (:type opts))) (and (#{:server-status :server-start :server-stop :server-restart} command) (not (seq (:repo opts)))) (missing-repo-result summary) :else - (ok-result command opts args summary)))) + (command-core/ok-result command opts args summary)))) -(defn- cli-error->result - [summary {:keys [msg]}] - (invalid-options-result summary (or msg "invalid options"))) +;; CLI error handling is in logseq.cli.command.core. (defn parse-args [raw-args] - (let [summary (top-level-summary table) - {:keys [opts args]} (parse-leading-global-opts raw-args)] + (let [summary (command-core/top-level-summary table) + legacy-graph-opt? (command-core/legacy-graph-opt? raw-args) + {:keys [opts args]} (command-core/parse-leading-global-opts raw-args)] + (if legacy-graph-opt? + (command-core/invalid-options-result summary "unknown option: --graph") (if (empty? args) (if (:help opts) - (help-result summary) + (command-core/help-result summary) {:ok? false :error {:code :missing-command :message "missing command"} - :summary summary}) + :summary summary}) (if (and (= 1 (count args)) (#{"graph" "server" "list" "add" "remove"} (first args))) - (help-result (group-summary (first args) table)) + (command-core/help-result (command-core/group-summary (first args) table)) (try (let [result (cli/dispatch table args {:spec global-spec})] (if (nil? result) - (unknown-command-result summary (str "unknown command: " (string/join " " args))) + (command-core/unknown-command-result summary (str "unknown command: " (string/join " " args))) (finalize-command summary (update result :opts #(merge opts (or % {})))))) (catch :default e (let [{:keys [cause] :as data} (ex-data e)] (cond (= cause :input-exhausted) (if (:help opts) - (help-result summary) + (command-core/help-result summary) {:ok? false :error {:code :missing-command :message "missing command"} :summary summary}) (= cause :no-match) - (unknown-command-result summary (str "unknown command: " (unknown-command-message data))) + (command-core/unknown-command-result summary (str "unknown command: " (unknown-command-message data))) (some? data) - (cli-error->result summary data) + (command-core/cli-error->result summary data) :else - (unknown-command-result summary (str "unknown command: " (string/join " " args))))))))))) + (command-core/unknown-command-result summary (str "unknown command: " (string/join " " args)))))))))))) -(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 ""))) +;; Repo/graph helpers live in logseq.cli.command.core. (defn- ensure-existing-graph [action config] (if (and (:repo action) (not (:allow-missing-graph action))) (p/let [graphs (cli-server/list-graphs config) - graph (repo->graph (:repo action))] + graph (command-core/repo->graph (:repo action))] (if (some #(= graph %) graphs) {:ok? true} {:ok? false @@ -586,7 +245,7 @@ [action config] (if (and (= :graph-import (:type action)) (:repo action)) (p/let [graphs (cli-server/list-graphs config) - graph (repo->graph (:repo action))] + graph (command-core/repo->graph (:repo action))] (if (some #(= graph %) graphs) {:ok? false :error {:code :graph-exists @@ -594,978 +253,71 @@ {:ok? true})) (p/resolved {:ok? true}))) -(defn- pick-graph - [options command-args config] - (or (:repo options) - (first command-args) - (:repo config))) +;; Repo selection lives in logseq.cli.command.core. -(defn- read-blocks - [options command-args] - (cond - (seq (:blocks options)) - {:ok? true :value (reader/read-string (:blocks options))} +;; Block parsing lives in logseq.cli.command.add. - (seq (:blocks-file options)) - (let [contents (.toString (fs/readFileSync (:blocks-file options)) "utf8")] - {:ok? true :value (reader/read-string contents)}) +;; Add/remove helpers live in logseq.cli.command.add/remove. - (seq (:content options)) - {:ok? true :value [{:block/title (:content options)}]} +;; Show helpers live in logseq.cli.command.show. - (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"}})) +;; Show helpers live in logseq.cli.command.show. -(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))) +;; Repo normalization lives in logseq.cli.command.core. -(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- 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})))) - -(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- resolve-repo - [graph] - (let [graph (some-> graph string/trim)] - (when (seq graph) - (graph->repo graph)))) - -(defn- missing-graph-error - [] - {:ok? false - :error {:code :missing-graph - :message "graph name is required"}}) - -(defn- missing-repo-error - [message] - {:ok? false - :error {:code :missing-repo - :message message}}) - -(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- 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 (repo->graph repo) - :allow-missing-graph true - :persist-repo (repo->graph repo)}}) - - :graph-switch - (if-not (seq graph) - (missing-graph-error) - {:ok? true - :action {:type :graph-switch - :command :graph-switch - :repo repo - :graph (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 (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 (repo->graph repo)}}) - - :graph-info - (if-not (seq repo) - (missing-graph-error) - {:ok? true - :action {:type :graph-info - :command :graph-info - :repo repo - :graph (repo->graph repo)}}))) - -(defn- build-server-action - [command repo] - (case command - :server-list - {:ok? true - :action {:type :server-list}} - - :server-status - (if-not (seq repo) - (missing-repo-error "repo is required for server status") - {:ok? true - :action {:type :server-status - :repo repo}}) - - :server-start - (if-not (seq repo) - (missing-repo-error "repo is required for server start") - {:ok? true - :action {:type :server-start - :repo repo}}) - - :server-stop - (if-not (seq repo) - (missing-repo-error "repo is required for server stop") - {:ok? true - :action {:type :server-stop - :repo repo}}) - - :server-restart - (if-not (seq repo) - (missing-repo-error "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- build-add-block-action - [options args repo] - (if-not (seq repo) - (missing-repo-error "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 (repo->graph repo) - :page (:page options) - :parent (:parent options) - :blocks (:value vector-result)}})))))) - -(defn- build-add-page-action - [options repo] - (if-not (seq repo) - (missing-repo-error "repo is required for add") - (let [page (some-> (:page options) string/trim)] - (if (seq page) - {:ok? true - :action {:type :add-page - :repo repo - :graph (repo->graph repo) - :page page}} - {:ok? false - :error {:code :missing-page-name - :message "page name is required"}})))) - -(defn- build-remove-block-action - [options repo] - (if-not (seq repo) - (missing-repo-error "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) - (missing-repo-error "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- build-list-action - [command options repo] - (if-not (seq repo) - (missing-repo-error "repo is required for list") - {:ok? true - :action {:type command - :repo repo - :options options}})) - -(defn- build-search-action - [options args repo] - (if-not (seq repo) - (missing-repo-error "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- build-show-action - [options repo] - (if-not (seq repo) - (missing-repo-error "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}})))) +;; Command-specific errors live in subcommand namespaces. (defn build-action [parsed config] (if-not (:ok? parsed) parsed (let [{:keys [command options args]} parsed - graph (pick-graph options args config) - repo (resolve-repo graph) - server-repo (resolve-repo (:repo options))] + graph (command-core/pick-graph options args config) + repo (command-core/resolve-repo graph) + server-repo (command-core/resolve-repo (:repo options))] (case command (:graph-list :graph-create :graph-switch :graph-remove :graph-validate :graph-info) - (build-graph-action command graph repo) + (graph-command/build-graph-action command graph repo) :graph-export - (let [export-type (normalize-import-export-type (:type options))] - (if-not (seq repo) - (missing-repo-error "repo is required for export") - {:ok? true - :action {:type :graph-export - :repo repo - :graph (repo->graph repo) - :export-type export-type - :output (:output options)}})) + (let [export-type (graph-command/normalize-import-export-type (:type options))] + (graph-command/build-export-action repo export-type (:output options))) :graph-import - (let [import-repo (resolve-repo (:repo options)) - import-type (normalize-import-export-type (:type options))] - (if-not (seq import-repo) - (missing-repo-error "repo is required for import") - {:ok? true - :action {:type :graph-import - :repo import-repo - :graph (repo->graph import-repo) - :import-type import-type - :input (:input options) - :allow-missing-graph true}})) + (let [import-repo (command-core/resolve-repo (:repo options)) + import-type (graph-command/normalize-import-export-type (:type options))] + (graph-command/build-import-action import-repo import-type (:input options))) (:server-list :server-status :server-start :server-stop :server-restart) - (build-server-action command server-repo) + (server-command/build-action command server-repo) (:list-page :list-tag :list-property) - (build-list-action command options repo) + (list-command/build-action command options repo) :add-block - (build-add-block-action options args repo) + (add-command/build-add-block-action options args repo) :add-page - (build-add-page-action options repo) + (add-command/build-add-page-action options repo) :remove-block - (build-remove-block-action options repo) + (remove-command/build-remove-block-action options repo) :remove-page - (build-remove-page-action options repo) + (remove-command/build-remove-page-action options repo) :search - (build-search-action options args repo) + (search-command/build-action options args repo) :show - (build-show-action options repo) + (show-command/build-action options repo) {:ok? false :error {:code :unknown-command :message (str "unknown command: " command)}})))) -(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))}}))) - -(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}}))) - -(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}}))) - -(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}}))) - -(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}}))) - -(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)}})))) - -(defn- server-result->response - [result] - (if (:ok? result) - {:status :ok - :data (:data result)} - {:status :error - :error (:error result)})) - -(defn- execute-server-list - [_action config] - (-> (p/let [servers (cli-server/list-servers config)] - {:status :ok - :data {:servers servers}}))) - -(defn- execute-server-status - [action config] - (-> (p/let [result (cli-server/server-status config (:repo action))] - (server-result->response result)))) - -(defn- execute-server-start - [action config] - (-> (p/let [result (cli-server/start-server! config (:repo action))] - (server-result->response result)))) - -(defn- execute-server-stop - [action config] - (-> (p/let [result (cli-server/stop-server! config (:repo action))] - (server-result->response result)))) - -(defn- execute-server-restart - [action config] - (-> (p/let [result (cli-server/restart-server! config (:repo action))] - (server-result->response result)))) - (defn execute [action config] (-> (p/let [missing-check (ensure-missing-graph action config) @@ -1581,26 +333,26 @@ :else (case (:type action) - :graph-list (execute-graph-list action config) - :invoke (execute-invoke action config) - :graph-switch (execute-graph-switch action config) - :graph-info (execute-graph-info action config) - :graph-export (execute-graph-export action config) - :graph-import (execute-graph-import action config) - :list-page (execute-list-page action config) - :list-tag (execute-list-tag action config) - :list-property (execute-list-property action config) - :add-block (execute-add-block action config) - :add-page (execute-add-page action config) - :remove-block (execute-remove action config) - :remove-page (execute-remove action config) - :search (execute-search action config) - :show (execute-show action config) - :server-list (execute-server-list action config) - :server-status (execute-server-status action config) - :server-start (execute-server-start action config) - :server-stop (execute-server-stop action config) - :server-restart (execute-server-restart action config) + :graph-list (graph-command/execute-graph-list action config) + :invoke (graph-command/execute-invoke action config) + :graph-switch (graph-command/execute-graph-switch action config) + :graph-info (graph-command/execute-graph-info action config) + :graph-export (graph-command/execute-graph-export action config) + :graph-import (graph-command/execute-graph-import action config) + :list-page (list-command/execute-list-page action config) + :list-tag (list-command/execute-list-tag action config) + :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) + :remove-block (remove-command/execute-remove action config) + :remove-page (remove-command/execute-remove action config) + :search (search-command/execute-search action config) + :show (show-command/execute-show action config) + :server-list (server-command/execute-list action config) + :server-status (server-command/execute-status action config) + :server-start (server-command/execute-start action config) + :server-stop (server-command/execute-stop action config) + :server-restart (server-command/execute-restart action config) {:status :error :error {:code :unknown-action :message "unknown action"}}))] diff --git a/src/main/logseq/cli/format.cljs b/src/main/logseq/cli/format.cljs index e70cc0bfa9..4b872ea8d6 100644 --- a/src/main/logseq/cli/format.cljs +++ b/src/main/logseq/cli/format.cljs @@ -68,7 +68,7 @@ (defn- error-hint [{:keys [code]}] (case code - :missing-graph "Use --graph " + :missing-graph "Use --repo " :missing-repo "Use --repo " :missing-content "Use --content or pass content as args" :missing-search-text "Provide search text or --text" diff --git a/src/main/logseq/cli/transport.cljs b/src/main/logseq/cli/transport.cljs index b24ad93645..fc39591fa5 100644 --- a/src/main/logseq/cli/transport.cljs +++ b/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))] diff --git a/src/test/frontend/worker/db_worker_node_test.cljs b/src/test/frontend/worker/db_worker_node_test.cljs index 3e889483dd..677d60dc03 100644 --- a/src/test/frontend/worker/db_worker_node_test.cljs +++ b/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) diff --git a/src/test/logseq/cli/commands_test.cljs b/src/test/logseq/cli/commands_test.cljs index 1d6bbe4657..7d99d921ab 100644 --- a/src/test/logseq/cli/commands_test.cljs +++ b/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))) diff --git a/src/test/logseq/cli/format_test.cljs b/src/test/logseq/cli/format_test.cljs index 60bc40559b..7ab050f1d7 100644 --- a/src/test/logseq/cli/format_test.cljs +++ b/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 ") + "Hint: Use --repo ") result))))) diff --git a/src/test/logseq/cli/transport_test.cljs b/src/test/logseq/cli/transport_test.cljs index 03ea866e42..5cb0f553a0 100644 --- a/src/test/logseq/cli/transport_test.cljs +++ b/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")))))) diff --git a/tmp_scripts/db-worker-smoke-test.clj b/tmp_scripts/db-worker-smoke-test.clj deleted file mode 100644 index 0debde9741..0000000000 --- a/tmp_scripts/db-worker-smoke-test.clj +++ /dev/null @@ -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") diff --git a/tmp_scripts/db-worker-sse-smoke-test.clj b/tmp_scripts/db-worker-sse-smoke-test.clj deleted file mode 100644 index c343acc996..0000000000 --- a/tmp_scripts/db-worker-sse-smoke-test.clj +++ /dev/null @@ -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")))