enhance(cli): options cleanup in list cmds

This commit is contained in:
rcmerci
2026-04-08 12:53:19 +08:00
parent 653a004195
commit bdc87e4edc
10 changed files with 482 additions and 74 deletions

View File

@@ -1,8 +1,8 @@
(ns logseq.cli.command.list
"List-related CLI commands."
(:require [clojure.string :as string]
[logseq.cli.command.add :as add-command]
[logseq.cli.command.core :as core]
[logseq.cli.command.task-status :as task-status-command]
[logseq.cli.server :as cli-server]
[logseq.cli.transport :as transport]
[promesa.core :as p]))
@@ -124,13 +124,11 @@
(merge-with
merge
list-common-spec
{:status {:desc "Filter by task status"
:validate #{"todo" "doing" "done" "now" "later" "wait" "waiting"
"backlog" "canceled" "cancelled"
"in-review" "in_review" "inreview" "in-progress"}}
{:status {:desc "Filter by task status"}
:priority {:desc "Filter by task priority"
:validate #{"low" "medium" "high" "urgent"}}
:content {:desc "Filter by task title content"}
:content {:desc "Filter by task title content"
:alias :c}
:sort {:validate (set (keys list-task-field-map))}
:fields {:multiple-values (keys list-task-field-map)}}))
@@ -279,18 +277,29 @@
[action config]
(-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
options (:options action)
normalized-options (cond-> options
(seq (some-> (:status options) string/trim))
(assoc :status (add-command/normalize-status (:status options)))
(seq (some-> (:priority options) string/trim))
(assoc :priority (normalize-priority (:priority options))))
items (transport/invoke cfg :thread-api/cli-list-tasks false
[(:repo action) normalized-options])
sort-field (effective-sort-field normalized-options)
order (or (:order normalized-options) "asc")
fields (parse-field-list (:fields normalized-options))
sorted (apply-sort items sort-field order list-task-field-map)
limited (apply-offset-limit sorted (:offset normalized-options) (:limit normalized-options))
final (apply-fields limited fields list-task-field-map)]
{:status :ok
:data {:items final}})))
status-input (some-> (:status options) string/trim)
available-statuses (when (seq status-input)
(transport/invoke cfg :thread-api/q false
[(:repo action)
[task-status-command/status-closed-values-query]]))
resolved-status (when (seq status-input)
(task-status-command/resolve-status-ident status-input available-statuses))]
(if (and (seq status-input) (not resolved-status))
{:status :error
:error {:code :invalid-options
:message (task-status-command/invalid-status-message status-input available-statuses)}}
(let [normalized-options (cond-> options
resolved-status
(assoc :status resolved-status)
(seq (some-> (:priority options) string/trim))
(assoc :priority (normalize-priority (:priority options))))]
(p/let [items (transport/invoke cfg :thread-api/cli-list-tasks false
[(:repo action) normalized-options])
sort-field (effective-sort-field normalized-options)
order (or (:order normalized-options) "asc")
fields (parse-field-list (:fields normalized-options))
sorted (apply-sort items sort-field order list-task-field-map)
limited (apply-offset-limit sorted (:offset normalized-options) (:limit normalized-options))
final (apply-fields limited fields list-task-field-map)]
{:status :ok
:data {:items final}}))))))

View File

@@ -0,0 +1,74 @@
(ns logseq.cli.command.task-status
"Runtime task status helpers for graph-derived validation."
(:require [clojure.string :as string]
[logseq.cli.command.add :as add-command]))
(def status-closed-values-query
'[:find [?status-ident ...]
:where
[?property :db/ident :logseq.property/status]
[?value :block/closed-value-property ?property]
[?value :db/ident ?status-ident]])
(defn- status-ident->value
[ident]
(when (keyword? ident)
(let [n (name ident)]
(when (string/starts-with? n "status.")
(subs n (count "status."))))))
(defn- normalize-token
[value]
(some-> value
str
string/trim
string/lower-case
(string/replace #"^:+" "")
(string/replace #"^logseq\.property/status\." "")
(string/replace #"^status\." "")
(string/replace #"[\s_]+" "-")))
(defn normalize-available-statuses
"Normalize db-worker status values into sorted maps of
`{:ident <kw> :value <string>}` for deterministic matching/output."
[statuses]
(->> statuses
(keep (fn [item]
(let [ident (cond
(keyword? item) item
(map? item) (:ident item)
:else nil)
value (or (when (map? item)
(some-> (:value item) normalize-token))
(some-> ident status-ident->value normalize-token))]
(when (and ident (seq value))
{:ident ident :value value}))))
(sort-by (juxt :value (comp str :ident)))
distinct
vec))
(defn resolve-status-ident
"Resolve user `status-input` to one of `available-statuses` idents.
Returns nil when unresolved."
[status-input available-statuses]
(let [available-statuses (normalize-available-statuses available-statuses)
available-idents (set (map :ident available-statuses))
by-value (into {} (map (juxt :value :ident) available-statuses))
legacy (add-command/normalize-status status-input)
token (normalize-token status-input)
ident-from-token (when (seq token)
(keyword "logseq.property" (str "status." token)))]
(or (when (contains? available-idents legacy) legacy)
(get by-value token)
(when (contains? available-idents ident-from-token)
ident-from-token))))
(defn invalid-status-message
[status-input available-statuses]
(let [values (map :value (normalize-available-statuses available-statuses))
available-text (if (seq values)
(string/join ", " values)
"(none)")]
(str "Invalid value for option :status: " status-input
". Available values (from current graph): "
available-text)))

View File

@@ -3,6 +3,7 @@
(:require [clojure.string :as string]
[logseq.cli.command.add :as add-command]
[logseq.cli.command.core :as core]
[logseq.cli.command.task-status :as task-status-command]
[logseq.cli.command.update :as update-command]
[logseq.cli.server :as cli-server]
[logseq.cli.transport :as transport]
@@ -32,10 +33,6 @@
:blocks-file {:desc "EDN file of blocks [create only]"
:coerce common-graph/expand-home
:complete :file}
:status {:desc "Set task status"
:validate #{"todo" "doing" "done" "now" "later" "wait" "waiting"
"backlog" "canceled" "cancelled"
"in-review" "in_review" "inreview" "in-progress"}}
:update-tags {:desc "Tags to add/update (EDN vector)"}
:update-properties {:desc "Properties to add/update (EDN map)"}
:remove-tags {:desc "Tags to remove (EDN vector) [update only]"}
@@ -70,9 +67,7 @@
:complete :pages}
:pos {:desc "Position. Default: last-child"
:validate #{"first-child" "last-child" "sibling"}}
:status {:desc "Set task status"
:validate #{"todo" "doing" "done" "now" "later" "wait" "waiting"
"backlog" "canceled" "cancelled" "in-review" "in-progress"}}
:status {:desc "Set task status"}
:priority {:desc "Set task priority"
:validate #{"low" "medium" "high" "urgent"}}
:update-tags {:desc "Tags to add/update (EDN vector)"}
@@ -107,8 +102,7 @@
"logseq upsert block --graph my-graph --id 123 --content \"Updated content\""
"logseq upsert block --graph my-graph --id 123 --target-page Home"
"logseq upsert block --graph my-graph --target-page Meeting Notes --content \"AI summary of the discussion\" --update-tags '[\"AI-GENERATED\"]'"
"logseq upsert block --graph my-graph --blocks '[{:block/title \"A\"} {:block/title \"B\"}]'"
"logseq upsert block --graph my-graph --id 123 --status done"]})
"logseq upsert block --graph my-graph --blocks '[{:block/title \"A\"} {:block/title \"B\"}]'"]})
(core/command-entry ["upsert" "page"] :upsert-page "Upsert page" upsert-page-spec
{:examples ["logseq upsert page --graph my-graph --page Home --update-tags '[\"project\"]'"
"logseq upsert page --graph my-graph --id 999 --update-properties '{:logseq.property/description \"Example\"}'"]})
@@ -379,7 +373,7 @@
:error {:code :invalid-options
:message invalid-message}}
(and status-provided? (not status))
(and status-provided? (not (seq status-text)))
{:ok? false
:error {:code :invalid-options
:message (str "invalid status: " (:status options))}}
@@ -426,6 +420,7 @@
(some? id) (assoc :id id)
(seq uuid) (assoc :uuid uuid)
(seq page) (assoc :page page)
(seq status-text) (assoc :status-input status-text)
(and (seq content) (not= mode :page)) (assoc :content content))}))))
(defn build-tag-action
@@ -863,34 +858,65 @@
:error {:code (or (get-in (ex-data e) [:code]) :exception)
:message (or (ex-message e) (str e))}}))))
(defn- normalize-status-input
[value]
(when (some? value)
(let [text (string/trim (if (string? value) value (str value)))]
(when (seq text)
text))))
(defn- resolve-task-status-action
[action cfg]
(let [status-input (or (normalize-status-input (:status-input action))
(normalize-status-input (:status action)))]
(if (seq status-input)
(p/let [available-statuses (transport/invoke cfg :thread-api/q false
[(:repo action)
[task-status-command/status-closed-values-query]])
resolved-status (task-status-command/resolve-status-ident status-input available-statuses)]
(if resolved-status
{:ok? true
:action (-> action
(assoc :status resolved-status)
(dissoc :status-input))}
{:ok? false
:error {:code :invalid-options
:message (task-status-command/invalid-status-message status-input available-statuses)}}))
(p/resolved {:ok? true :action action}))))
(defn execute-upsert-task
[action config]
(-> (p/let [cfg (cli-server/ensure-server! config (:repo action))]
(case (:mode action)
:create
(p/let [result (add-command/execute-add-block (assoc action :type :add-block) config)
created-ids (vec (or (get-in result [:data :result]) []))
_ (execute-upsert-task-ops! action cfg created-ids)]
{:status :ok
:data {:result created-ids}})
:page
(p/let [page (ensure-page-entity! cfg (:repo action) (:page action))
page-id (:db/id page)
_ (execute-upsert-task-ops! action cfg [page-id])]
{:status :ok
:data {:result [page-id]}})
:update
(p/let [entity (ensure-task-node! cfg (:repo action) action)
node-id (:db/id entity)
_ (execute-upsert-task-ops! action cfg [node-id])]
{:status :ok
:data {:result [node-id]}})
(-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
status-check (resolve-task-status-action action cfg)]
(if-not (:ok? status-check)
{:status :error
:error {:code :invalid-options
:message "invalid upsert task mode"}}))
:error (:error status-check)}
(let [action* (:action status-check)]
(case (:mode action*)
:create
(p/let [result (add-command/execute-add-block (assoc action* :type :add-block) config)
created-ids (vec (or (get-in result [:data :result]) []))
_ (execute-upsert-task-ops! action* cfg created-ids)]
{:status :ok
:data {:result created-ids}})
:page
(p/let [page (ensure-page-entity! cfg (:repo action*) (:page action*))
page-id (:db/id page)
_ (execute-upsert-task-ops! action* cfg [page-id])]
{:status :ok
:data {:result [page-id]}})
:update
(p/let [entity (ensure-task-node! cfg (:repo action*) action*)
node-id (:db/id entity)
_ (execute-upsert-task-ops! action* cfg [node-id])]
{:status :ok
:data {:result [node-id]}})
{:status :error
:error {:code :invalid-options
:message "invalid upsert task mode"}}))))
(p/catch (fn [e]
{:status :error
:error {:code (or (get-in (ex-data e) [:code]) :exception)

View File

@@ -181,6 +181,10 @@
(re-find #"Unknown option:\s*:properties" (or message "")))
"unknown option: --properties; use --update-properties"
(and (= ["upsert" "block"] subcommand)
(re-find #"Unknown option:\s*:status" (or message "")))
"unknown option: --status; use upsert task --status"
(and (= ["upsert" "page"] subcommand)
(re-find #"Unknown option:\s*:tags" (or message "")))
"unknown option: --tags; use --update-tags"

View File

@@ -92,6 +92,11 @@
add-command/resolve-property-identifiers (fn [_ _ _ _] (p/resolved []))
transport/invoke (fn [_ method _ args]
(case method
:thread-api/q
(p/resolved [:logseq.property/status.todo
:logseq.property/status.doing
:logseq.property/status.done])
:thread-api/pull
(let [[_ selector lookup] args]
(cond

View File

@@ -1092,7 +1092,13 @@
(is (= 10 (get-in result [:options :limit])))
(is (= 2 (get-in result [:options :offset])))
(is (= "priority" (get-in result [:options :sort])))
(is (= "desc" (get-in result [:options :order]))))))
(is (= "desc" (get-in result [:options :order])))))
(testing "list task supports short -c alias for --content"
(let [result (commands/parse-args ["list" "task" "-c" "alpha"])]
(is (true? (:ok? result)))
(is (= :list-task (:command result)))
(is (= "alpha" (get-in result [:options :content]))))))
(deftest test-search-subcommand-parse
(testing "search block parses --content option"
@@ -1181,7 +1187,13 @@
(testing "list task rejects invalid priority"
(let [result (commands/parse-args ["list" "task" "--priority" "wat"])]
(is (false? (:ok? result)))
(is (= :invalid-options (get-in result [:error :code]))))))
(is (= :invalid-options (get-in result [:error :code])))))
(testing "list task defers unknown --status to runtime validation"
(let [result (commands/parse-args ["list" "task" "--status" "custom-review"])]
(is (true? (:ok? result)))
(is (= :list-task (:command result)))
(is (= "custom-review" (get-in result [:options :status]))))))
(deftest test-list-execute-default-sort-updated-at
(async done
@@ -1255,6 +1267,67 @@
(is false (str "unexpected error: " e))))
(p/finally done))))
(deftest test-task-runtime-invalid-status-includes-graph-values
(async done
(let [list-calls* (atom [])
upsert-calls* (atom [])]
(-> (p/with-redefs [cli-server/ensure-server! (fn [_ _] {:base-url "http://example"})
transport/invoke (fn [_ method _ args]
(let [repo (first args)]
(cond
(= method :thread-api/q)
(do
(if (= repo "demo")
(swap! list-calls* conj method)
(swap! upsert-calls* conj method))
[:logseq.property/status.todo
:logseq.property/status.done
:logseq.property/status.doing])
(= method :thread-api/cli-list-tasks)
(do
(swap! list-calls* conj method)
[])
(= method :thread-api/pull)
(do
(swap! upsert-calls* conj method)
{:db/id 1})
(= method :thread-api/apply-outliner-ops)
(do
(swap! upsert-calls* conj method)
{:result :ok})
:else
(throw (ex-info "unexpected invoke" {:method method :args args})))))]
(p/let [list-result (list-command/execute-list-task
{:repo "demo"
:options {:status "invalid-status"}}
{})
upsert-result (upsert-command/execute-upsert-task
{:repo "upsert-demo"
:mode :update
:id 1
:status "invalid-status"}
{})
list-message (or (some-> (get-in list-result [:error :message]) strip-ansi) "")
upsert-message (or (some-> (get-in upsert-result [:error :message]) strip-ansi) "")]
(is (= :error (:status list-result)))
(is (= :invalid-options (get-in list-result [:error :code])))
(is (string/includes? list-message "Invalid value for option :status: invalid-status"))
(is (string/includes? list-message "Available values (from current graph): doing, done, todo"))
(is (= [:thread-api/q] @list-calls*))
(is (= :error (:status upsert-result)))
(is (= :invalid-options (get-in upsert-result [:error :code])))
(is (string/includes? upsert-message "Invalid value for option :status: invalid-status"))
(is (string/includes? upsert-message "Available values (from current graph): doing, done, todo"))
(is (= [:thread-api/q] @upsert-calls*))))
(p/catch (fn [e]
(is false (str "unexpected error: " e))))
(p/finally done)))))
(deftest test-verb-subcommand-parse-upsert-remove
(testing "remove block parses with id"
(let [result (commands/parse-args ["remove" "block" "--id" "10"])]
@@ -1389,13 +1462,14 @@
(is (= :upsert-block (:command result)))
(is (= "11111111-1111-1111-1111-111111111111" (get-in result [:options :uuid])))))
(testing "upsert block update mode accepts status-only updates"
(testing "upsert block rejects --status and guides migration to upsert task"
(let [result (commands/parse-args ["upsert" "block" "--id" "1"
"--status" "done"])]
(is (true? (:ok? result)))
(is (= :upsert-block (:command result)))
(is (= 1 (get-in result [:options :id])))
(is (= "done" (get-in result [:options :status])))))
"--status" "done"])
message (strip-ansi (get-in result [:error :message]))]
(is (false? (:ok? result)))
(is (= :invalid-options (get-in result [:error :code])))
(is (string/includes? message "--status"))
(is (string/includes? message "upsert task"))))
(testing "upsert block update mode accepts content-only updates"
(let [result (commands/parse-args ["upsert" "block" "--id" "1"
@@ -1563,6 +1637,14 @@
(is (= "done" (get-in result [:options :status])))
(is (= "medium" (get-in result [:options :priority])))))
(testing "upsert task defers unknown --status to runtime validation"
(let [result (commands/parse-args ["upsert" "task"
"--id" "42"
"--status" "custom-review"])]
(is (true? (:ok? result)))
(is (= :upsert-task (:command result)))
(is (= "custom-review" (get-in result [:options :status])))))
(testing "upsert task requires selector, page, or content"
(let [result (commands/parse-args ["upsert" "task"])]
(is (false? (:ok? result)))

View File

@@ -54,7 +54,8 @@
(let [entries list-command/entries
page-entry (first (filter #(= :list-page (:command %)) entries))
tag-entry (first (filter #(= :list-tag (:command %)) entries))
property-entry (first (filter #(= :list-property (:command %)) entries))]
property-entry (first (filter #(= :list-property (:command %)) entries))
task-entry (first (filter #(= :list-task (:command %)) entries))]
(testing "page-spec :sort has some correct values"
(is (contains? (get-in page-entry [:spec :sort :validate]) "title")))
(testing "tag-spec :sort has some correct values"
@@ -68,7 +69,9 @@
(let [mv (get-in tag-entry [:spec :fields :multiple-values])]
(is (seq mv))
(is (some #{"title"} mv))
(is (some #{"uuid"} mv))))))
(is (some #{"uuid"} mv))))
(testing "list task :content has -c alias"
(is (= :c (get-in task-entry [:spec :content :alias]))))))
(deftest test-upsert-spec-metadata
(let [entries upsert-command/entries
@@ -79,8 +82,8 @@
(testing "block-spec :pos has :validate set"
(is (= #{"first-child" "last-child" "sibling"}
(get-in block-entry [:spec :pos :validate]))))
(testing "block-spec :status has :validate set"
(is (seq (get-in block-entry [:spec :status :validate]))))
(testing "block-spec does not expose :status option"
(is (nil? (get-in block-entry [:spec :status]))))
(testing "block-spec :target-page has :complete :pages"
(is (= :pages (get-in block-entry [:spec :target-page :complete]))))
(testing "block-spec :blocks-file has :complete :file"
@@ -275,7 +278,8 @@
(is (not (string/includes? output "-c[Path to cli.edn (default ~/logseq/cli.edn)]:file:_files'"))))
(testing "-c is available as content alias in command-specific completion"
(is (re-find #"(?s)_logseq_search_block\(\).*?-c\[Search content text\]" output))
(is (re-find #"(?s)_logseq_upsert_block\(\).*?-c\[Block content" output)))
(is (re-find #"(?s)_logseq_upsert_block\(\).*?-c\[Block content" output))
(is (re-find #"(?s)_logseq_list_task\(\).*?-c\[Filter by task title content\]" output)))
(testing ":alias emits grouping without --no- for global flags"
(is (re-find #"\(-h --help\)" output)))))