From 9855011e62df71a8a1fb71f2bd8d0328417e33b3 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Tue, 7 Apr 2026 22:39:04 +0800 Subject: [PATCH] feat(cli): add task related cmds --- cli-e2e/spec/non_sync_cases.edn | 16 + cli-e2e/spec/non_sync_inventory.edn | 10 +- .../078-logseq-cli-task-subcommands.md | 195 +++++++++++ docs/cli/logseq-cli.md | 8 +- src/main/frontend/worker/db_core.cljs | 5 + src/main/logseq/cli/command/list.cljs | 62 +++- src/main/logseq/cli/command/upsert.cljs | 305 +++++++++++++++++- src/main/logseq/cli/commands.cljs | 20 +- src/main/logseq/cli/common/db_worker.cljs | 39 ++- src/main/logseq/cli/format.cljs | 38 +++ src/test/logseq/cli/command/upsert_test.cljs | 76 ++++- src/test/logseq/cli/commands_test.cljs | 288 +++++++++++------ .../logseq/cli/common/db_worker_test.cljs | 62 ++++ src/test/logseq/cli/format_test.cljs | 210 ++++++------ 14 files changed, 1138 insertions(+), 196 deletions(-) create mode 100644 docs/agent-guide/078-logseq-cli-task-subcommands.md diff --git a/cli-e2e/spec/non_sync_cases.edn b/cli-e2e/spec/non_sync_cases.edn index b15ec2b16f..dd1fac0800 100644 --- a/cli-e2e/spec/non_sync_cases.edn +++ b/cli-e2e/spec/non_sync_cases.edn @@ -148,6 +148,22 @@ :cleanup ["{{cli}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{graph-arg}}"] :tags [:upsert :list :smoke]} + {:id "task-upsert-and-list-json" + :setup ["{{cli}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{graph-arg}} >/dev/null"] + :cmds ["{{cli}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json upsert task --graph {{graph-arg}} --page TaskHome --status todo --priority high >/dev/null" + "{{cli}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json list task --graph {{graph-arg}} --status todo --priority high --content task --fields id,title,status,priority --sort updated-at --order desc --limit 1"] + :expect {:exit 0 + :stdout-json-paths {[:status] "ok" + [:data :items 0 :block/title] "TaskHome" + [:data :items 0 :logseq.property/status] "logseq.property/status.todo" + [:data :items 0 :logseq.property/priority] "logseq.property/priority.high"}} + :covers {:commands ["upsert task" "list task"] + :options {:global ["--config" "--graph" "--data-dir" "--output"] + :upsert ["--page" "--status" "--priority"] + :list ["--status" "--priority" "--content" "--fields" "--limit" "--sort" "--order"]}} + :cleanup ["{{cli}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{graph-arg}}"] + :tags [:upsert :list :smoke]} + {:id "block-upsert-and-show-json" :setup ["{{cli}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{graph-arg}} >/dev/null" "{{cli}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null"] diff --git a/cli-e2e/spec/non_sync_inventory.edn b/cli-e2e/spec/non_sync_inventory.edn index 5cbe543f97..9f042a5ed3 100644 --- a/cli-e2e/spec/non_sync_inventory.edn +++ b/cli-e2e/spec/non_sync_inventory.edn @@ -33,22 +33,27 @@ :list {:commands ["list page" "list tag" - "list property"] + "list property" + "list task"] :options ["--expand" "--fields" "--limit" "--offset" "--sort" "--order" + "--status" + "--priority" + "--content" "--journal-only" "--with-properties" "--with-extends" "--with-classes" - "--with-type"]} + "--with-type"]} :upsert {:commands ["upsert block" "upsert page" + "upsert task" "upsert tag" "upsert property"] :options ["--id" @@ -60,6 +65,7 @@ "--content" "--blocks-file" "--status" + "--priority" "--update-tags" "--update-properties" "--remove-tags" diff --git a/docs/agent-guide/078-logseq-cli-task-subcommands.md b/docs/agent-guide/078-logseq-cli-task-subcommands.md new file mode 100644 index 0000000000..55cf77bfd8 --- /dev/null +++ b/docs/agent-guide/078-logseq-cli-task-subcommands.md @@ -0,0 +1,195 @@ +# Logseq CLI Task Subcommands Implementation Plan + +Goal: Add task-oriented subcommands to the current `logseq-cli` command surface with `list task` and `upsert task`, where a task is any node tagged with `#Task` (`:logseq.class/Task`), including both blocks and pages. + +Architecture: Reuse the existing `logseq-cli -> transport/invoke -> db-worker-node thread-api` flow, and keep command parsing/build/validation in CLI command namespaces with db read logic in db-worker helpers. + +Architecture: Keep behavior aligned with current `list`/`upsert`/`remove` conventions, and avoid introducing a parallel command framework for tasks. + +Tech Stack: ClojureScript, `babashka.cli`, `promesa`, Datascript pull/query, existing `apply-outliner-ops` mutation path in db-worker. + +Related: Builds on [069-logseq-cli-search-subcommands.md](/Users/rcmerci/gh-repos/logseq/docs/agent-guide/069-logseq-cli-search-subcommands.md), [071-logseq-cli-search-content-option.md](/Users/rcmerci/gh-repos/logseq/docs/agent-guide/071-logseq-cli-search-content-option.md), and [docs/cli/logseq-cli.md](/Users/rcmerci/gh-repos/logseq/docs/cli/logseq-cli.md). + +## Problem statement + +Current CLI supports generic node operations (`list page/tag/property`, `upsert block/page/tag/property`, `remove block/page/tag/property`) but has no task-focused command path. + +The product model already has built-in class `:logseq.class/Task` and task properties (`status`, `priority`, `deadline`, `scheduled`), so users currently need multiple generic commands or custom queries for common task workflows. + +We need a first-class task command group that maps to existing implementation patterns and is script-friendly. + +## Current baseline from implementation + +`list` commands already expose pagination and sort contracts and depend on db-worker helper functions via `:thread-api/cli-list-*`. + +`upsert block/page` already supports tag/property mutation via `--update-tags`, `--update-properties`, `--remove-tags`, and `--remove-properties`. + +`remove` commands already implement robust selector validation, name/id disambiguation, and best-effort multi-id behavior for blocks. + +Built-in class `:logseq.class/Task` exists and is treated as a tag class with task properties in db bootstrap. + +## Command surface proposal (for discussion first) + +This section is intentionally a proposal draft so we can converge on options before implementation. + +### 1) `list task` options proposal + +Recommended MVP options: + +| Option | Type | Default | Notes | +| --- | --- | --- | --- | +| `--status` | status alias | none | Accept same aliases as current `upsert block --status` normalization. | +| `--priority` | `low|medium|high|urgent` | none | Task priority filter. | +| `--content` | string | none | Case-insensitive substring filter on `:block/title`. | +| `--fields` | csv | command default | Same pattern as existing `list` commands. | +| `--limit` | long | none | Same as existing `list`. | +| `--offset` | long | none | Same as existing `list`. | +| `--sort` | enum | `updated-at` | Proposed sort fields: `updated-at`, `created-at`, `title`, `status`, `priority`. | +| `--order` | `asc|desc` | `asc` | Same as existing `list`. | + +Recommended default output fields: + +`id,title,status,priority,scheduled,deadline,updated-at,created-at`. + +Phase 2 optional filters: + +`--scheduled-after`, `--scheduled-before`, `--deadline-after`, `--deadline-before`, and multi-status support. + +### 2) `upsert task` options proposal + +Recommended MVP options: + +| Option | Type | Purpose | +| --- | --- | --- | +| `--id` | long | Update an existing node as task by db/id. | +| `--uuid` | uuid | Update an existing node as task by UUID. | +| `--page` | string | Upsert a page task by page name. | +| `--content` | string | Create a block task with content. | +| `--target-id` | long | Block create target selector. | +| `--target-uuid` | uuid | Block create target selector. | +| `--target-page` | string | Block create target selector. | +| `--pos` | `first-child|last-child|sibling` | Block create position control. | +| `--status` | status alias | Set `:logseq.property/status`. | +| `--priority` | enum | Set `:logseq.property/priority.*`. | +| `--update-properties` | edn map | Advanced property mutation. | +| `--remove-properties` | edn vector | Advanced property removal. | +| `--update-tags` | edn vector | Optional extra tags beyond `#Task`. | +| `--remove-tags` | edn vector | Optional tag removal with guardrails for `#Task`. | + +Recommended semantics: + +`upsert task` always ensures `:logseq.class/Task` tag exists on the target node. + +When selector is `--id` or `--uuid`, command runs in update mode and converts non-task node into task by adding `:logseq.class/Task`. + +When selector is `--page`, command upserts page and ensures task tag plus task properties. + +When selector is `--content` and no id/page selector is provided, command creates block task and supports current block targeting options. + +Key validation proposal: + +Only one of `--id`, `--uuid`, `--page` is allowed. + +`--content` and `--page` cannot be combined. + +`--target-*` and `--pos` are only valid for block-create path. + +Task removal strategy: + +Do not add `remove task` in this scope. + +Use existing `remove block` and `remove page` commands for deletion. + +## Proposed architecture and files + +Keep `list task` in list command flow and add db-worker helper method to avoid large query logic in CLI command layer. + +Implement `upsert task` by reusing existing upsert/tag/property helper functions and `apply-outliner-ops` patterns. + +Primary files to touch: + +- [/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/list.cljs](/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/list.cljs) +- [/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/upsert.cljs](/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/upsert.cljs) +- [/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/commands.cljs](/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/commands.cljs) +- [/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/format.cljs](/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/format.cljs) +- [/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/common/db_worker.cljs](/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/common/db_worker.cljs) +- [/Users/rcmerci/gh-repos/logseq/src/main/frontend/worker/db_core.cljs](/Users/rcmerci/gh-repos/logseq/src/main/frontend/worker/db_core.cljs) +- [/Users/rcmerci/gh-repos/logseq/docs/cli/logseq-cli.md](/Users/rcmerci/gh-repos/logseq/docs/cli/logseq-cli.md) +- [/Users/rcmerci/gh-repos/logseq/cli-e2e/spec/non_sync_inventory.edn](/Users/rcmerci/gh-repos/logseq/cli-e2e/spec/non_sync_inventory.edn) +- [/Users/rcmerci/gh-repos/logseq/cli-e2e/spec/non_sync_cases.edn](/Users/rcmerci/gh-repos/logseq/cli-e2e/spec/non_sync_cases.edn) + +## Testing Plan + +I will follow @test-driven-development and add all failing tests before implementation code changes. + +I will add parser and validation tests in [commands_test.cljs](/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs) for new command paths and option conflicts. + +I will add command execution tests for new task logic in [upsert_test.cljs](/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/command/upsert_test.cljs) and existing remove/list test files, or create focused task command test namespaces if coverage becomes too broad. + +I will add db-worker behavior tests for task listing in [db_worker_test.cljs](/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/common/db_worker_test.cljs). + +I will add output formatting tests in [format_test.cljs](/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/format_test.cljs) for human/json/edn task command outputs. + +I will extend CLI e2e inventory and non-sync cases for `list task` and `upsert task`. + +I will run focused tests first and then run `bb dev:lint-and-test`. + +NOTE: I will write *all* tests before I add any implementation behavior. + +## Step-by-step implementation plan + +1. Add command entries for `list task` and `upsert task` with proposed option specs and examples. + +2. Wire new command keywords into parse-time validation in [commands.cljs](/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/commands.cljs). + +3. Add build-action branches for new task commands and keep error codes aligned with existing style (`:missing-target`, `:invalid-options`, `:missing-page-name`). + +4. Add execute branch wiring for new task command types in [commands.cljs](/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/commands.cljs). + +5. Implement `list task` db-worker helper query in [db_worker.cljs](/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/common/db_worker.cljs) and expose it via new `:thread-api/cli-list-tasks` in [db_core.cljs](/Users/rcmerci/gh-repos/logseq/src/main/frontend/worker/db_core.cljs). + +6. Implement `execute-list-task` in [list.cljs](/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/list.cljs) with existing sort/fields/offset/limit helpers. + +7. Implement `build-task-action` and `execute-upsert-task` in [upsert.cljs](/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/upsert.cljs) by composing existing tag/property resolution utilities and task-specific normalization for status and priority. + +8. Add `format` support for new command result shapes in [format.cljs](/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/format.cljs). + +9. Update docs and CLI e2e coverage files once behavior is stable. + +## Edge cases to cover explicitly + +A target node can exist but not have `#Task`, and command should fail with explicit type-mismatch style error instead of silently mutating or deleting wrong entities. + +Task pages and task blocks must both be supported for list and upsert. + +`upsert task` status aliases should match current `upsert block --status` normalization so scripts can reuse existing values. + +`--scheduled` and `--deadline` are explicitly out of MVP and should be handled later via dedicated options or via `--update-properties`. + +`list task` sort behavior must stay stable with deterministic tie-breaker by `:db/id`, consistent with current list commands. + +## Testing Details + +I will verify parse/build/execute behavior at command layer and confirm db-worker helper output contracts without relying on ad hoc manual checks. + +I will ensure tests assert behavior for both block task and page task paths, including conversion of non-task nodes during upsert when selector-based update is used. + +I will include at least one e2e case that creates task via `upsert task` and lists it via `list task` in one flow. + +## Implementation Details + +- Keep task identity canonical as `:block/tags` containing `:logseq.class/Task`. +- Reuse existing normalization helpers for status and property parsing to minimize duplicate parsing logic. +- Prefer extending existing command namespaces over introducing a second task-only command framework. +- Reuse existing list formatting utilities with task-specific column mapping. +- Keep error code naming aligned with current conventions to avoid inconsistent CLI UX. +- Keep JSON namespaced key behavior unchanged by routing through existing formatter normalization. +- Ensure command help examples include both block and page task use cases. +- Keep cli-e2e inventory and docs in lockstep with final option names. +- Defer non-MVP filters (`deadline`/`scheduled` ranges, multi-status) and `upsert task` dedicated `--scheduled/--deadline` options. + +## Question + +No open questions. + +--- diff --git a/docs/cli/logseq-cli.md b/docs/cli/logseq-cli.md index e6f1d501d7..9b8d242ead 100644 --- a/docs/cli/logseq-cli.md +++ b/docs/cli/logseq-cli.md @@ -213,6 +213,7 @@ Inspect and edit commands: - `list page [--expand] [--limit ] [--offset ] [--sort ] [--order asc|desc]` - list pages (defaults to `--sort updated-at`) - `list tag [--expand] [--limit ] [--offset ] [--sort ] [--order asc|desc]` - list tags (defaults to `--sort updated-at`) - `list property [--expand] [--limit ] [--offset ] [--sort ] [--order asc|desc]` - list properties (defaults to `--sort updated-at`; `TYPE` and `CARDINALITY` are included by default even without `--expand`; missing schema cardinality is treated as `one`) +- `list task [--status ] [--priority ] [--content ] [--fields ] [--limit ] [--offset ] [--sort ] [--order asc|desc]` - list task nodes tagged with `#Task` (supports both pages and blocks; defaults to `--sort updated-at`) - `upsert block --content [--target-page |--target-id |--target-uuid ] [--pos first-child|last-child|sibling]` - create blocks; defaults to today’s journal page if no target is given - `upsert block --blocks [--target-page |--target-id |--target-uuid ] [--pos first-child|last-child|sibling]` - insert blocks via EDN vector - `upsert block --blocks-file [--target-page |--target-id |--target-uuid ] [--pos first-child|last-child|sibling]` - insert blocks from an EDN file @@ -220,6 +221,9 @@ Inspect and edit commands: - When both `--status` and `--update-properties` set `:logseq.property/status`, the value from `--update-properties` takes precedence. - `upsert page --page [--update-tags ] [--update-properties ] [--remove-tags ] [--remove-properties ]` - create (or update by page name) a page - `upsert page --id [--update-tags ] [--update-properties ] [--remove-tags ] [--remove-properties ]` - update a page by id (cannot be combined with `--page`) +- `upsert task --content [--target-page |--target-id |--target-uuid ] [--pos first-child|last-child|sibling] [--status ] [--priority ] [--update-tags ] [--update-properties ] [--remove-tags ] [--remove-properties ]` - create a task block and ensure `#Task` is attached +- `upsert task --page [--status ] [--priority ] [--update-tags ] [--update-properties ] [--remove-tags ] [--remove-properties ]` - create/update a task page and ensure `#Task` is attached +- `upsert task --id |--uuid [--status ] [--priority ] [--update-tags ] [--update-properties ] [--remove-tags ] [--remove-properties ]` - update an existing node and ensure `#Task` is attached - `upsert tag --name ` - create or upsert a tag by name - `upsert tag --id [--name ]` - validate a tag by id; when `--name` is provided, rename that tag id (no-op if normalized name is unchanged) - `upsert tag --id --name ` conflicts: returns `tag-name-conflict` when target name is a non-tag page, and `tag-rename-conflict` when target name is another existing tag @@ -245,8 +249,10 @@ Subcommands: list page [options] List pages list tag [options] List tags list property [options] List properties + list task [options] List tasks upsert block [options] Upsert block upsert page [options] Upsert page + upsert task [options] Upsert task upsert tag [options] Upsert tag upsert property [options] Upsert property move [options] Move block @@ -305,7 +311,7 @@ JSON key migration (flat -> namespaced): | `data.items[].type` | `data.items[].logseq.property/type` | | `data.items[].cardinality` | `data.items[].db/cardinality` | | `data.root.children[]` | `data.root.block/children[]` | -- `upsert page` and `upsert block` return entity ids in `data.result` for JSON/EDN output, and include ids in human output. +- `upsert page`, `upsert block`, and `upsert task` return entity ids in `data.result` for JSON/EDN output, and include ids in human output. - Human example: ```text Upserted page: diff --git a/src/main/frontend/worker/db_core.cljs b/src/main/frontend/worker/db_core.cljs index d4b6e6dbd6..ca168da33b 100644 --- a/src/main/frontend/worker/db_core.cljs +++ b/src/main/frontend/worker/db_core.cljs @@ -1098,6 +1098,11 @@ (let [conn (worker-state/get-datascript-conn repo)] (cli-db-worker/list-pages @conn options))) +(def-thread-api :thread-api/cli-list-tasks + [repo options] + (let [conn (worker-state/get-datascript-conn repo)] + (cli-db-worker/list-tasks @conn options))) + ;; API server specific fns start with 'api-' (def-thread-api :thread-api/api-get-page-data [repo page-title] diff --git a/src/main/logseq/cli/command/list.cljs b/src/main/logseq/cli/command/list.cljs index d3af1ba7ff..779343131a 100644 --- a/src/main/logseq/cli/command/list.cljs +++ b/src/main/logseq/cli/command/list.cljs @@ -1,6 +1,7 @@ (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.server :as cli-server] [logseq.cli.transport :as transport] @@ -24,6 +25,12 @@ (def ^:private default-sort-field "updated-at") +(def ^:private task-priority-aliases + {"low" :logseq.property/priority.low + "medium" :logseq.property/priority.medium + "high" :logseq.property/priority.high + "urgent" :logseq.property/priority.urgent}) + (defn- effective-sort-field [options] (or (:sort options) default-sort-field)) @@ -103,6 +110,30 @@ :default true :coerce :boolean}})) +(def ^:private list-task-field-map + {"id" :db/id + "title" :block/title + "status" :logseq.property/status + "priority" :logseq.property/priority + "scheduled" :logseq.property/scheduled + "deadline" :logseq.property/deadline + "updated-at" :block/updated-at + "created-at" :block/created-at}) + +(def ^:private list-task-spec + (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"}} + :priority {:desc "Filter by task priority" + :validate #{"low" "medium" "high" "urgent"}} + :content {:desc "Filter by task title content"} + :sort {:validate (set (keys list-task-field-map))} + :fields {:multiple-values (keys list-task-field-map)}})) + (def entries [(core/command-entry ["list" "page"] :list-page "List pages" list-page-spec {:examples ["logseq list page --graph my-graph" @@ -113,7 +144,10 @@ "logseq list tag --graph my-graph --include-built-in --limit 20 --output json"]}) (core/command-entry ["list" "property"] :list-property "List properties" list-property-spec {:examples ["logseq list property --graph my-graph --with-type" - "logseq list property --graph my-graph --include-built-in --limit 20 --output json"]})]) + "logseq list property --graph my-graph --include-built-in --limit 20 --output json"]}) + (core/command-entry ["list" "task"] :list-task "List tasks" list-task-spec + {:examples ["logseq list task --graph my-graph --status todo --priority high" + "logseq list task --graph my-graph --content \"release\" --sort updated-at --order desc"]})]) (defn invalid-options? [opts] @@ -234,3 +268,29 @@ final (apply-fields limited fields list-property-field-map)] {:status :ok :data {:items final}}))) + +(defn- normalize-priority + [value] + (let [text (some-> value string/trim string/lower-case)] + (when (seq text) + (get task-priority-aliases text)))) + +(defn execute-list-task + [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}}))) diff --git a/src/main/logseq/cli/command/upsert.cljs b/src/main/logseq/cli/command/upsert.cljs index 07f6f36477..536161c7be 100644 --- a/src/main/logseq/cli/command/upsert.cljs +++ b/src/main/logseq/cli/command/upsert.cljs @@ -34,7 +34,8 @@ :complete :file} :status {:desc "Set task status" :validate #{"todo" "doing" "done" "now" "later" "wait" "waiting" - "backlog" "canceled" "cancelled" "in-review" "in-progress"}} + "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]"} @@ -50,6 +51,35 @@ :remove-tags {:desc "Tags to remove (EDN vector) [update only]"} :remove-properties {:desc "Properties to remove (EDN vector) [update only]"}}) +(def ^:private upsert-task-spec + {:id {:desc "Target node db/id (forces update mode) [update only]" + :coerce :long} + :uuid {:desc "Target node UUID (forces update mode) [update only]" + :validate {:pred (comp parse-uuid str) + :ex-msg (constantly "Option uuid must be a valid UUID string")}} + :page {:desc "Task page name" + :complete :pages} + :content {:alias :c + :desc "Task block content (create mode)"} + :target-id {:desc "Target block db/id [create only]" + :coerce :long} + :target-uuid {:desc "Target block UUID [create only]" + :validate {:pred (comp parse-uuid str) + :ex-msg (constantly "Option target-uuid must be a valid UUID string")}} + :target-page {:desc "Target page name [create only]" + :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"}} + :priority {:desc "Set task priority" + :validate #{"low" "medium" "high" "urgent"}} + :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]"} + :remove-properties {:desc "Properties to remove (EDN vector) [update only]"}}) + (def ^:private upsert-tag-spec {:id {:desc "Target tag db/id (forces update mode)" :coerce :long} @@ -82,6 +112,10 @@ (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\"}'"]}) + (core/command-entry ["upsert" "task"] :upsert-task "Upsert task" upsert-task-spec + {:examples ["logseq upsert task --graph my-graph --content \"Ship release\" --target-page Home --status todo --priority high" + "logseq upsert task --graph my-graph --page Weekly Plan --status doing" + "logseq upsert task --graph my-graph --id 123 --status done"]}) (core/command-entry ["upsert" "tag"] :upsert-tag "Upsert tag" upsert-tag-spec {:examples ["logseq upsert tag --graph my-graph --name project" "logseq upsert tag --graph my-graph --id 200 --name Project Renamed"]}) @@ -113,6 +147,18 @@ "db.cardinality/many" "many" v))) +(def ^:private priority-aliases + {"low" :logseq.property/priority.low + "medium" :logseq.property/priority.medium + "high" :logseq.property/priority.high + "urgent" :logseq.property/priority.urgent}) + +(defn- normalize-priority + [value] + (let [text (some-> value string/trim string/lower-case)] + (when (seq text) + (get priority-aliases text)))) + (defn invalid-options? [command opts] (case command @@ -138,6 +184,42 @@ (when (> (count selectors) 1) "only one of --id or --page is allowed")) + :upsert-task + (let [id (:id opts) + uuid (some-> (:uuid opts) string/trim) + page (some-> (:page opts) string/trim) + content (some-> (:content opts) string/trim) + selectors (filter some? [id uuid page]) + target-selectors (filter some? [(:target-id opts) + (:target-uuid opts) + (some-> (:target-page opts) string/trim)]) + pos (some-> (:pos opts) string/trim string/lower-case) + selector-mode? (or (some? id) (seq uuid) (seq page))] + (cond + (> (count selectors) 1) + "only one of --id, --uuid, or --page is allowed" + + (and (seq page) (seq content)) + "--content and --page are mutually exclusive" + + (and (or (some? id) (seq uuid)) (seq content)) + "--content is only valid when creating a block task" + + (> (count target-selectors) 1) + "only one of --target-id, --target-uuid, or --target-page is allowed" + + (and selector-mode? (or (seq target-selectors) (seq pos))) + "--target-* and --pos are only valid when creating a block task with --content" + + (and (seq pos) (empty? target-selectors)) + "--pos is only valid when a target option is provided" + + (and (= pos "sibling") (seq (some-> (:target-page opts) string/trim))) + "--pos sibling is only valid for block targets" + + :else + nil)) + :upsert-tag (let [name-provided? (contains? opts :name) name (normalize-tag-name (:name opts))] @@ -250,6 +332,102 @@ (some? id) (assoc :id id) (seq page) (assoc :page page))})))) +(defn build-task-action + [options repo] + (if-not (seq repo) + {:ok? false + :error {:code :missing-repo + :message "repo is required for upsert"}} + (let [id (:id options) + uuid (some-> (:uuid options) string/trim) + page (some-> (:page options) string/trim) + content (some-> (:content options) string/trim) + status-provided? (contains? options :status) + status-text (some-> (:status options) string/trim) + status (when (seq status-text) + (add-command/normalize-status status-text)) + priority-provided? (contains? options :priority) + priority-text (some-> (:priority options) string/trim) + priority (when (seq priority-text) + (normalize-priority priority-text)) + task-properties (cond-> {} + status (assoc :logseq.property/status status) + priority (assoc :logseq.property/priority priority)) + update-tags-result (add-command/parse-tags-option (:update-tags options)) + update-properties-result (add-command/parse-properties-option + (:update-properties options) + {:allow-non-built-in? true}) + remove-tags-result (add-command/parse-tags-vector-option (:remove-tags options)) + remove-properties-result (add-command/parse-properties-vector-option + (:remove-properties options) + {:allow-non-built-in? true}) + mode (cond + (seq page) :page + (or (some? id) (seq uuid)) :update + :else :create) + create-options (cond-> options + (seq (:target-page options)) + (assoc :target-page-name (:target-page options)) + true + (dissoc :target-page)) + create-result (when (= mode :create) + (add-command/build-add-block-action create-options [] repo)) + invalid-message (invalid-options? :upsert-task options)] + (cond + (seq invalid-message) + {:ok? false + :error {:code :invalid-options + :message invalid-message}} + + (and status-provided? (not status)) + {:ok? false + :error {:code :invalid-options + :message (str "invalid status: " (:status options))}} + + (and priority-provided? (not priority)) + {:ok? false + :error {:code :invalid-options + :message (str "invalid priority: " (:priority options))}} + + (and (not (some? id)) (not (seq uuid)) (not (seq page)) (not (seq content))) + {:ok? false + :error {:code :missing-target + :message "block or page is required"}} + + (not (:ok? update-tags-result)) + update-tags-result + + (not (:ok? update-properties-result)) + update-properties-result + + (not (:ok? remove-tags-result)) + remove-tags-result + + (not (:ok? remove-properties-result)) + remove-properties-result + + (and (= mode :create) (not (:ok? create-result))) + create-result + + :else + {:ok? true + :action (cond-> (if (= mode :create) + (-> (:action create-result) + (assoc :type :upsert-task)) + {:type :upsert-task + :repo repo + :graph (core/repo->graph repo)}) + true (assoc :mode mode + :update-tags (:value update-tags-result) + :update-properties (merge (or (:value update-properties-result) {}) + task-properties) + :remove-tags (:value remove-tags-result) + :remove-properties (:value remove-properties-result)) + (some? id) (assoc :id id) + (seq uuid) (assoc :uuid uuid) + (seq page) (assoc :page page) + (and (seq content) (not= mode :page)) (assoc :content content))})))) + (defn build-tag-action [options repo] (if-not (seq repo) @@ -497,6 +675,98 @@ :else entity))) +(def ^:private task-selector + [:db/id :block/uuid :block/name :block/title]) + +(def ^:private task-tag-ident + :logseq.class/Task) + +(defn- normalize-lookup-uuid + [value] + (cond + (uuid? value) value + (and (string? value) (common-util/uuid-string? (string/trim value))) + (uuid (string/trim value)) + :else nil)) + +(defn- pull-entity-by-uuid + [config repo selector uuid-value] + (when-let [uuid* (normalize-lookup-uuid uuid-value)] + (transport/invoke config :thread-api/pull false + [repo selector [:block/uuid uuid*]]))) + +(defn- ensure-task-node! + [config repo {:keys [id uuid]}] + (p/let [entity (cond + (some? id) + (pull-entity-by-id config repo task-selector id) + + (seq uuid) + (pull-entity-by-uuid config repo task-selector uuid) + + :else + nil)] + (if (:db/id entity) + entity + (throw (ex-info "node not found for selector" + {:code upsert-id-not-found-code + :id id + :uuid uuid}))))) + +(defn- ensure-task-tag-id! + [config repo] + (p/let [entity (transport/invoke config :thread-api/pull false + [repo [:db/id] [:db/ident task-tag-ident]])] + (if-let [tag-id (:db/id entity)] + tag-id + (throw (ex-info "task tag not found" + {:code :task-tag-not-found}))))) + +(defn- task-property-overrides + [action] + (cond-> {} + (:status action) (assoc :logseq.property/status (:status action)) + (:priority action) (assoc :logseq.property/priority (:priority action)))) + +(declare append-tag-and-property-ops) + +(defn- execute-upsert-task-ops! + [action cfg block-ids] + (if (seq block-ids) + (p/let [task-tag-id (ensure-task-tag-id! cfg (:repo action)) + update-tags (add-command/resolve-tags cfg (:repo action) (:update-tags action)) + remove-tags (add-command/resolve-tags cfg (:repo action) (:remove-tags action)) + update-properties (add-command/resolve-properties cfg (:repo action) (:update-properties action) + {:allow-non-built-in? true}) + update-properties (merge (or update-properties {}) + (task-property-overrides action)) + remove-properties (add-command/resolve-property-identifiers cfg (:repo action) + (:remove-properties action) + {:allow-non-built-in? true}) + _ (ensure-property-identifiers-exist! cfg (:repo action) (keys update-properties)) + _ (ensure-property-identifiers-exist! cfg (:repo action) remove-properties) + update-tag-ids (->> update-tags + (map :db/id) + (remove nil?) + (cons task-tag-id) + distinct + vec) + remove-tag-ids (->> remove-tags (map :db/id) (remove nil?) distinct vec) + _ (when (some #{task-tag-id} remove-tag-ids) + (throw (ex-info "cannot remove #Task tag in upsert task" + {:code :invalid-options + :message "cannot remove #Task tag in upsert task"}))) + ops (append-tag-and-property-ops [] + block-ids + {:update-tag-ids update-tag-ids + :remove-tag-ids remove-tag-ids + :update-properties update-properties + :remove-properties remove-properties})] + (when (seq ops) + (transport/invoke cfg :thread-api/apply-outliner-ops false + [(:repo action) ops {}]))) + (p/resolved nil))) + (defn- append-tag-and-property-ops [ops block-ids {:keys [update-tag-ids remove-tag-ids update-properties remove-properties]}] (cond-> ops @@ -593,6 +863,39 @@ :error {:code (or (get-in (ex-data e) [:code]) :exception) :message (or (ex-message e) (str e))}})))) +(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]}}) + + {: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) + :message (or (ex-message e) (str e))}})))) + (defn execute-upsert-tag [action config] (-> (p/let [cfg (cli-server/ensure-server! config (:repo action)) diff --git a/src/main/logseq/cli/commands.cljs b/src/main/logseq/cli/commands.cljs index 47a1ab55aa..03e241fdb1 100644 --- a/src/main/logseq/cli/commands.cljs +++ b/src/main/logseq/cli/commands.cljs @@ -239,6 +239,16 @@ (not (seq (:page opts)))) (missing-page-name-result summary) + (and (= command :upsert-task) (upsert-command/invalid-options? command opts)) + (command-core/invalid-options-result summary (upsert-command/invalid-options? command opts)) + + (and (= command :upsert-task) + (not (some? (:id opts))) + (not (seq (some-> (:uuid opts) string/trim))) + (not (seq (some-> (:page opts) string/trim))) + (not (seq (some-> (:content opts) string/trim)))) + (missing-target-result summary) + (and (= command :upsert-tag) (upsert-command/invalid-options? command opts)) (command-core/invalid-options-result summary (upsert-command/invalid-options? command opts)) @@ -284,7 +294,7 @@ (not (seq (some-> (:content opts) str string/trim)))) (assoc (missing-query-text-result summary) :command command) - (and (#{:list-page :list-tag :list-property} command) + (and (#{:list-page :list-tag :list-property :list-task} command) (list-command/invalid-options? opts)) (command-core/invalid-options-result summary (list-command/invalid-options? opts)) @@ -513,7 +523,7 @@ (:server-list :server-status :server-start :server-stop :server-restart) (server-command/build-action command server-repo) - (:list-page :list-tag :list-property) + (:list-page :list-tag :list-property :list-task) (list-command/build-action command options repo) (:search-block :search-page :search-property :search-tag) @@ -525,6 +535,9 @@ :upsert-page (upsert-command/build-page-action options repo) + :upsert-task + (upsert-command/build-task-action options repo) + :upsert-tag (upsert-command/build-tag-action options repo) @@ -598,12 +611,14 @@ :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) + :list-task (list-command/execute-list-task action config) :search-block (search-command/execute-search-block action config) :search-page (search-command/execute-search-page action config) :search-property (search-command/execute-search-property action config) :search-tag (search-command/execute-search-tag action config) :upsert-block (upsert-command/execute-upsert-block action config) :upsert-page (upsert-command/execute-upsert-page action config) + :upsert-task (upsert-command/execute-upsert-task action config) :upsert-tag (upsert-command/execute-upsert-tag action config) :upsert-property (upsert-command/execute-upsert-property action config) :remove-block (remove-command/execute-remove-block action config) @@ -640,6 +655,7 @@ :schema :query :lookup :selector :source :target :update-tags :update-properties :remove-tags :remove-properties + :status :priority :src :dst :backup-name :export-type :file :import-type :input :graph-id :email :config-key :config-value]))))) diff --git a/src/main/logseq/cli/common/db_worker.cljs b/src/main/logseq/cli/common/db_worker.cljs index af3aebc75c..523a04fec9 100644 --- a/src/main/logseq/cli/common/db_worker.cljs +++ b/src/main/logseq/cli/common/db_worker.cljs @@ -1,6 +1,7 @@ (ns logseq.cli.common.db-worker "Cli fns for use with db-worker" - (:require [datascript.core :as d] + (:require [clojure.string :as string] + [datascript.core :as d] [logseq.db :as ldb] [logseq.db.frontend.entity-util :as entity-util] [logseq.db.frontend.property :as db-property])) @@ -72,6 +73,41 @@ (update :logseq.property/description db-property/property-value-content)) (minimal-list-item e))))))) +(defn- ref->ident + [value] + (cond + (keyword? value) value + (map? value) (:db/ident value) + :else nil)) + +(defn- minimal-task-item + [e] + (cond-> (minimal-list-item e) + true (assoc :logseq.property/status (ref->ident (:logseq.property/status e)) + :logseq.property/priority (ref->ident (:logseq.property/priority e)) + :logseq.property/scheduled (:logseq.property/scheduled e) + :logseq.property/deadline (:logseq.property/deadline e)))) + +(defn list-tasks + "List task nodes (both pages and blocks) tagged with :logseq.class/Task." + [db {:keys [status priority content]}] + (let [status* (ref->ident status) + priority* (ref->ident priority) + content* (some-> content str string/lower-case)] + (->> (d/datoms db :avet :block/tags :logseq.class/Task) + (map #(d/entity db (:e %))) + (remove (fn [e] + (and status* + (not= status* (ref->ident (:logseq.property/status e)))))) + (remove (fn [e] + (and priority* + (not= priority* (ref->ident (:logseq.property/priority e)))))) + (remove (fn [e] + (and (seq content*) + (not (string/includes? (string/lower-case (or (:block/title e) "")) + content*))))) + (map minimal-task-item)))) + (defn- parse-time [value] (cond @@ -118,4 +154,3 @@ (cond-> (:db/ident e) (assoc :db/ident (:db/ident e))) (update :block/uuid str)) (minimal-list-item e))))))) - diff --git a/src/main/logseq/cli/format.cljs b/src/main/logseq/cli/format.cljs index de2236f43d..5d44e3e7d2 100644 --- a/src/main/logseq/cli/format.cljs +++ b/src/main/logseq/cli/format.cljs @@ -271,6 +271,38 @@ [items now-ms] (format-list-dynamic items now-ms list-property-columns)) +(defn- format-task-choice + [value prefix] + (let [ident (cond + (keyword? value) value + (map? value) (:db/ident value) + :else nil)] + (cond + ident (let [name' (name ident)] + (if (string/starts-with? name' prefix) + (subs name' (count prefix)) + name')) + (string? value) value + :else "-"))) + +(def ^:private list-task-columns + [["ID" (fn [item _] (or (:db/id item) (:id item))) [:db/id :id]] + ["TITLE" (fn [item _] (or (:title item) (:block/title item) (:name item))) [:title :block/title :name]] + ["STATUS" (fn [item _] (format-task-choice (or (:status item) (:logseq.property/status item)) "status.")) + [:status :logseq.property/status] true] + ["PRIORITY" (fn [item _] (format-task-choice (or (:priority item) (:logseq.property/priority item)) "priority.")) + [:priority :logseq.property/priority] true] + ["SCHEDULED" (fn [item _] (or (:scheduled item) (:logseq.property/scheduled item) "-")) + [:scheduled :logseq.property/scheduled] true] + ["DEADLINE" (fn [item _] (or (:deadline item) (:logseq.property/deadline item) "-")) + [:deadline :logseq.property/deadline] true] + ["UPDATED-AT" (fn [item now-ms] (human-ago (or (:updated-at item) (:block/updated-at item)) now-ms)) [:updated-at :block/updated-at] true] + ["CREATED-AT" (fn [item now-ms] (human-ago (or (:created-at item) (:block/created-at item)) now-ms)) [:created-at :block/created-at] true]]) + +(defn- format-list-task + [items now-ms] + (format-list-dynamic items now-ms list-task-columns)) + (defn- quote-posix-shell [value] (str "'" (string/replace (normalize-cell value) #"'" "'\"'\"'") "'")) @@ -642,6 +674,10 @@ [_context ids] (str "Upserted page:\n" (pr-str (vec (or ids []))))) +(defn- format-upsert-task + [_context ids] + (str "Upserted task:\n" (pr-str (vec (or ids []))))) + (defn- format-upsert-tag [_context ids] (str "Upserted tag:\n" (pr-str (vec (or ids []))))) @@ -776,10 +812,12 @@ :list-page (format-list-page (:items data) now-ms) :list-tag (format-list-tag (:items data) now-ms) :list-property (format-list-property (:items data) now-ms) + :list-task (format-list-task (:items data) now-ms) (:search-block :search-page :search-property :search-tag) (format-list-page (:items data) now-ms) :upsert-block (format-upsert-block context (:result data)) :upsert-page (format-upsert-page context (:result data)) + :upsert-task (format-upsert-task context (:result data)) :upsert-tag (format-upsert-tag context (:result data)) :upsert-property (format-upsert-property context (:result data)) :remove-block (format-remove-block context) diff --git a/src/test/logseq/cli/command/upsert_test.cljs b/src/test/logseq/cli/command/upsert_test.cljs index 6fb9d5f0ae..61e1f2c2eb 100644 --- a/src/test/logseq/cli/command/upsert_test.cljs +++ b/src/test/logseq/cli/command/upsert_test.cljs @@ -1,5 +1,6 @@ (ns logseq.cli.command.upsert-test - (:require [cljs.test :refer [async deftest is]] + (:require [cljs.test :refer [async deftest is testing]] + [logseq.cli.command.add :as add-command] [logseq.cli.command.upsert :as upsert-command] [logseq.cli.server :as cli-server] [logseq.cli.transport :as transport] @@ -51,3 +52,76 @@ (p/catch (fn [e] (is false (str "unexpected error: " e)))) (p/finally done))))) + +(deftest test-build-task-action-validation + (testing "upsert task requires target selector or content/page" + (let [result (upsert-command/build-task-action {} "logseq_db_demo")] + (is (false? (:ok? result))) + (is (= :missing-target (get-in result [:error :code]))))) + + (testing "upsert task rejects page and content combination" + (let [result (upsert-command/build-task-action {:page "Home" :content "Task"} "logseq_db_demo")] + (is (false? (:ok? result))) + (is (= :invalid-options (get-in result [:error :code]))))) + + (testing "upsert task build create mode" + (let [result (upsert-command/build-task-action {:content "Task from CLI" + :status "todo" + :priority "high"} + "logseq_db_demo")] + (is (true? (:ok? result))) + (is (= :upsert-task (get-in result [:action :type]))) + (is (= :create (get-in result [:action :mode]))) + (is (= :logseq.property/status.todo (get-in result [:action :update-properties :logseq.property/status]))) + (is (= :logseq.property/priority.high (get-in result [:action :update-properties :logseq.property/priority])))))) + +(deftest test-execute-upsert-task-page-applies-task-ops + (async done + (let [ops* (atom nil) + action {:type :upsert-task + :mode :page + :repo "demo-repo" + :graph "demo-graph" + :page "TaskHome" + :status :logseq.property/status.todo + :priority :logseq.property/priority.high}] + (-> (p/with-redefs [cli-server/ensure-server! (fn [config _repo] + (p/resolved (assoc config :base-url "http://example"))) + add-command/resolve-tags (fn [_ _ _] (p/resolved nil)) + add-command/resolve-properties (fn [_ _ _ _] (p/resolved {})) + add-command/resolve-property-identifiers (fn [_ _ _ _] (p/resolved [])) + transport/invoke (fn [_ method _ args] + (case method + :thread-api/pull + (let [[_ selector lookup] args] + (cond + (= lookup [:block/name "taskhome"]) + (p/resolved {:db/id 42 :block/uuid (uuid "00000000-0000-0000-0000-000000000042")}) + + (= lookup [:db/ident :logseq.class/Task]) + (p/resolved {:db/id 900}) + + (and (vector? selector) (= selector [:db/id])) + (p/resolved {:db/id 1}) + + :else + (p/resolved {}))) + + :thread-api/apply-outliner-ops + (let [[_ ops _] args] + (reset! ops* ops) + (p/resolved nil)) + + (throw (ex-info "unexpected invoke" + {:method method + :args args}))))] + (p/let [result (upsert-command/execute-upsert-task action {})] + (is (= :ok (:status result))) + (is (= [42] (get-in result [:data :result]))) + (is (= [[:batch-set-property [[42] :block/tags 900 {}]] + [:batch-set-property [[42] :logseq.property/status :logseq.property/status.todo {}]] + [:batch-set-property [[42] :logseq.property/priority :logseq.property/priority.high {}]]] + @ops*)))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally done))))) diff --git a/src/test/logseq/cli/commands_test.cljs b/src/test/logseq/cli/commands_test.cljs index 88d1d7ca96..5b2e905047 100644 --- a/src/test/logseq/cli/commands_test.cljs +++ b/src/test/logseq/cli/commands_test.cljs @@ -8,6 +8,7 @@ [logseq.db.frontend.rules :as rules] [logseq.cli.command.show :as show-command] [logseq.cli.command.sync :as sync-command] + [logseq.cli.command.upsert :as upsert-command] [logseq.cli.commands :as commands] [logseq.cli.server :as cli-server] [logseq.cli.style :as style] @@ -89,10 +90,12 @@ (is (contains-bold? summary "list page")) (is (contains-bold? summary "list tag")) (is (contains-bold? summary "list property")) + (is (contains-bold? summary "list task")) (is (contains-bold? summary "upsert block")) (is (contains-bold? summary "upsert page")) (is (contains-bold? summary "upsert tag")) (is (contains-bold? summary "upsert property")) + (is (contains-bold? summary "upsert task")) (is (contains-bold? summary "remove block")) (is (contains-bold? summary "remove page")) (is (contains-bold? summary "remove tag")) @@ -137,96 +140,55 @@ (is (string/includes? plain-summary "Global options:")) (is (string/includes? plain-summary "Command options:"))))) -(deftest test-parse-args-help-groups - (testing "graph group shows subcommands" - (let [result (binding [style/*color-enabled?* true] - (commands/parse-args ["graph"])) - summary (:summary result) - plain-summary (strip-ansi summary)] - (is (true? (:help? result))) - (is (string/includes? plain-summary "graph list")) - (is (string/includes? plain-summary "graph create")) - (is (string/includes? plain-summary "graph export")) - (is (string/includes? plain-summary "graph import")) - (is (contains-bold? summary "graph list")) - (is (contains-bold? summary "graph create")) - (is (contains-bold? summary "graph export")) - (is (contains-bold? summary "graph import")))) +(deftest test-parse-args-help-groups-primary + (testing "graph/list/upsert/server groups show subcommands" + (doseq [[group plain-entries bold-entries] + [["graph" + ["graph list" "graph create" "graph export" "graph import"] + ["graph list" "graph create" "graph export" "graph import"]] + ["list" + ["list page" "list tag" "list property" "list task"] + ["list page" "list tag" "list property" "list task"]] + ["upsert" + ["upsert task" "upsert tag" "upsert property"] + ["upsert task" "upsert tag" "upsert property"]] + ["server" + ["server list" "server start"] + ["server list" "server start"]]]] + (let [result (binding [style/*color-enabled?* true] + (commands/parse-args [group])) + summary (:summary result) + plain-summary (strip-ansi summary)] + (is (true? (:help? result))) + (doseq [entry plain-entries] + (is (string/includes? plain-summary entry))) + (doseq [entry bold-entries] + (is (contains-bold? summary entry))) + (when (= "list" group) + (is (string/includes? plain-summary "Global options:")) + (is (string/includes? plain-summary "Command options:"))))))) - (testing "list group shows subcommands" - (let [result (binding [style/*color-enabled?* true] - (commands/parse-args ["list"])) - summary (:summary result) - plain-summary (strip-ansi summary)] - (is (true? (:help? result))) - (is (string/includes? plain-summary "list page")) - (is (string/includes? plain-summary "list tag")) - (is (string/includes? plain-summary "list property")) - (is (contains-bold? summary "list page")) - (is (contains-bold? summary "list tag")) - (is (contains-bold? summary "list property")) - (is (string/includes? plain-summary "Global options:")) - (is (string/includes? plain-summary "Command options:")))) - - (testing "upsert group shows subcommands" - (let [result (binding [style/*color-enabled?* true] - (commands/parse-args ["upsert"])) - summary (:summary result) - plain-summary (strip-ansi summary)] - (is (true? (:help? result))) - (is (string/includes? plain-summary "upsert tag")) - (is (string/includes? plain-summary "upsert property")) - (is (contains-bold? summary "upsert tag")) - (is (contains-bold? summary "upsert property")))) - - (testing "server group shows subcommands" - (let [result (binding [style/*color-enabled?* true] - (commands/parse-args ["server"])) - summary (:summary result) - plain-summary (strip-ansi summary)] - (is (true? (:help? result))) - (is (string/includes? plain-summary "server list")) - (is (string/includes? plain-summary "server start")) - (is (contains-bold? summary "server list")) - (is (contains-bold? summary "server start")))) - - (testing "query group shows subcommands" - (let [result (binding [style/*color-enabled?* true] - (commands/parse-args ["query"])) - summary (:summary result) - plain-summary (strip-ansi summary)] - (is (true? (:help? result))) - (is (string/includes? plain-summary "query list")) - (is (string/includes? plain-summary "query")) - (is (contains-bold? summary "query list")) - (is (contains-bold? summary "query")))) - - (testing "search group shows subcommands" - (let [result (binding [style/*color-enabled?* true] - (commands/parse-args ["search"])) - summary (:summary result) - plain-summary (strip-ansi summary)] - (is (true? (:help? result))) - (is (string/includes? plain-summary "search block")) - (is (string/includes? plain-summary "search page")) - (is (string/includes? plain-summary "search property")) - (is (string/includes? plain-summary "search tag")) - (is (contains-bold? summary "search block")) - (is (contains-bold? summary "search page")) - (is (contains-bold? summary "search property")) - (is (contains-bold? summary "search tag")))) - - (testing "example group shows selectors" - (let [result (binding [style/*color-enabled?* true] - (commands/parse-args ["example"])) - summary (:summary result) - plain-summary (strip-ansi summary)] - (is (true? (:help? result))) - (is (string/includes? plain-summary "example upsert")) - (is (string/includes? plain-summary "example upsert page")) - (is (string/includes? plain-summary "example show")) - (is (contains-bold? summary "example upsert")) - (is (contains-bold? summary "example show")))) +(deftest test-parse-args-help-groups-secondary + (testing "query/search/example groups show subcommands" + (doseq [[group plain-entries bold-entries] + [["query" + ["query list" "query"] + ["query list" "query"]] + ["search" + ["search block" "search page" "search property" "search tag"] + ["search block" "search page" "search property" "search tag"]] + ["example" + ["example upsert" "example upsert page" "example show"] + ["example upsert" "example show"]]]] + (let [result (binding [style/*color-enabled?* true] + (commands/parse-args [group])) + summary (:summary result) + plain-summary (strip-ansi summary)] + (is (true? (:help? result))) + (doseq [entry plain-entries] + (is (string/includes? plain-summary entry))) + (doseq [entry bold-entries] + (is (contains-bold? summary entry)))))) (testing "group help command list omits [options]" (let [summary (:summary (binding [style/*color-enabled?* true] @@ -446,10 +408,12 @@ (is (string/includes? plain-summary "upsert page")) (is (string/includes? plain-summary "upsert tag")) (is (string/includes? plain-summary "upsert property")) + (is (string/includes? plain-summary "upsert task")) (is (contains-bold? summary "upsert block")) (is (contains-bold? summary "upsert page")) (is (contains-bold? summary "upsert tag")) - (is (contains-bold? summary "upsert property"))))) + (is (contains-bold? summary "upsert property")) + (is (contains-bold? summary "upsert task"))))) (deftest test-parse-args-help-alignment (testing "graph group aligns subcommand columns" @@ -1107,7 +1071,28 @@ (is (true? (get-in result [:options :with-classes]))) (is (true? (get-in result [:options :with-type]))) (is (= "cardinality" (get-in result [:options :sort]))) - (is (= "name,type,cardinality" (get-in result [:options :fields])))))) + (is (= "name,type,cardinality" (get-in result [:options :fields]))))) + + (testing "list task parses" + (let [result (commands/parse-args ["list" "task" + "--status" "doing" + "--priority" "high" + "--content" "alpha" + "--fields" "id,title,status,priority" + "--limit" "10" + "--offset" "2" + "--sort" "priority" + "--order" "desc"])] + (is (true? (:ok? result))) + (is (= :list-task (:command result))) + (is (= "doing" (get-in result [:options :status]))) + (is (= "high" (get-in result [:options :priority]))) + (is (= "alpha" (get-in result [:options :content]))) + (is (= "id,title,status,priority" (get-in result [:options :fields]))) + (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])))))) (deftest test-search-subcommand-parse (testing "search block parses --content option" @@ -1185,6 +1170,16 @@ (testing "list property rejects invalid sort field" (let [result (commands/parse-args ["list" "property" "--sort" "wat"])] + (is (false? (:ok? result))) + (is (= :invalid-options (get-in result [:error :code]))))) + + (testing "list task rejects invalid sort field" + (let [result (commands/parse-args ["list" "task" "--sort" "wat"])] + (is (false? (:ok? result))) + (is (= :invalid-options (get-in result [:error :code]))))) + + (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])))))) @@ -1537,6 +1532,77 @@ (is (false? (:ok? result))) (is (= :unknown-command (get-in result [:error :code])))))) +(deftest test-verb-subcommand-parse-upsert-task-mode + (testing "upsert task parses block create mode" + (let [result (commands/parse-args ["upsert" "task" + "--content" "Ship CLI tasks" + "--target-page" "Home" + "--status" "todo" + "--priority" "high"])] + (is (true? (:ok? result))) + (is (= :upsert-task (:command result))) + (is (= "Ship CLI tasks" (get-in result [:options :content]))) + (is (= "Home" (get-in result [:options :target-page]))) + (is (= "todo" (get-in result [:options :status]))) + (is (= "high" (get-in result [:options :priority]))))) + + (testing "upsert task parses page mode" + (let [result (commands/parse-args ["upsert" "task" "--page" "Weekly Plan"])] + (is (true? (:ok? result))) + (is (= :upsert-task (:command result))) + (is (= "Weekly Plan" (get-in result [:options :page]))))) + + (testing "upsert task parses id update mode" + (let [result (commands/parse-args ["upsert" "task" + "--id" "42" + "--status" "done" + "--priority" "medium"])] + (is (true? (:ok? result))) + (is (= :upsert-task (:command result))) + (is (= 42 (get-in result [:options :id]))) + (is (= "done" (get-in result [:options :status]))) + (is (= "medium" (get-in result [:options :priority]))))) + + (testing "upsert task requires selector, page, or content" + (let [result (commands/parse-args ["upsert" "task"])] + (is (false? (:ok? result))) + (is (= :missing-target (get-in result [:error :code]))))) + + (testing "upsert task rejects selector conflicts" + (let [result (commands/parse-args ["upsert" "task" + "--id" "42" + "--uuid" "11111111-1111-1111-1111-111111111111"])] + (is (false? (:ok? result))) + (is (= :invalid-options (get-in result [:error :code]))))) + + (testing "upsert task rejects page and content combination" + (let [result (commands/parse-args ["upsert" "task" + "--page" "Home" + "--content" "Task block"])] + (is (false? (:ok? result))) + (is (= :invalid-options (get-in result [:error :code]))))) + + (testing "upsert task rejects id and content combination" + (let [result (commands/parse-args ["upsert" "task" + "--id" "42" + "--content" "Task block"])] + (is (false? (:ok? result))) + (is (= :invalid-options (get-in result [:error :code]))))) + + (testing "upsert task rejects target options in page mode" + (let [result (commands/parse-args ["upsert" "task" + "--page" "Home" + "--target-page" "Elsewhere"])] + (is (false? (:ok? result))) + (is (= :invalid-options (get-in result [:error :code]))))) + + (testing "upsert task rejects invalid priority" + (let [result (commands/parse-args ["upsert" "task" + "--content" "Alpha" + "--priority" "wat"])] + (is (false? (:ok? result))) + (is (= :invalid-options (get-in result [:error :code])))))) + (deftest test-verb-subcommand-parse-update-target-page (testing "upsert block update mode parses with target page" (let [result (commands/parse-args ["upsert" "block" "--id" "1" "--target-page" "Home"])] @@ -2020,6 +2086,12 @@ (is (false? (:ok? result))) (is (= :missing-repo (get-in result [:error :code]))))) + (testing "list task builds action" + (let [parsed {:ok? true :command :list-task :options {:status "todo"}} + result (commands/build-action parsed {:graph "demo"})] + (is (true? (:ok? result))) + (is (= :list-task (get-in result [:action :type]))))) + (testing "search page builds action from --content option" (let [parsed {:ok? true :command :search-page :options {:content "project home"} :args []} result (commands/build-action parsed {:graph "demo"})] @@ -2072,7 +2144,15 @@ result (commands/build-action parsed {:graph "demo"})] (is (true? (:ok? result))) (is (= :update (get-in result [:action :mode]))) - (is (= 42 (get-in result [:action :id])))))) + (is (= 42 (get-in result [:action :id]))))) + + (testing "upsert task builds action" + (let [parsed {:ok? true + :command :upsert-task + :options {:content "Task from CLI" :status "todo"}} + result (commands/build-action parsed {:graph "demo"})] + (is (true? (:ok? result))) + (is (= :upsert-task (get-in result [:action :type])))))) (deftest test-build-action-upsert-tag-property @@ -3013,6 +3093,30 @@ (p/catch (fn [e] (is false (str "unexpected error: " e)))) (p/finally done)))) +(deftest test-execute-task-dispatch + (async done + (let [calls* (atom [])] + (-> (p/with-redefs [cli-server/list-graphs (fn [_] ["demo"]) + list-command/execute-list-task (fn [action _] + (swap! calls* conj action) + (p/resolved {:status :ok + :data {:items []}})) + upsert-command/execute-upsert-task (fn [action _] + (swap! calls* conj action) + (p/resolved {:status :ok + :data {:result [101]}}))] + (p/let [list-result (commands/execute {:type :list-task :repo "logseq_db_demo"} {}) + upsert-result (commands/execute {:type :upsert-task :repo "logseq_db_demo"} {})] + (is (= :ok (:status list-result))) + (is (= :list-task (:command list-result))) + (is (= :ok (:status upsert-result))) + (is (= :upsert-task (:command upsert-result))) + (is (= [:list-task :upsert-task] + (mapv :type @calls*))))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally done))))) + (deftest test-execute-requires-existing-graph (async done (-> (p/with-redefs [cli-server/list-graphs (fn [_] []) diff --git a/src/test/logseq/cli/common/db_worker_test.cljs b/src/test/logseq/cli/common/db_worker_test.cljs index 48c64a98aa..02ffe7bf03 100644 --- a/src/test/logseq/cli/common/db_worker_test.cljs +++ b/src/test/logseq/cli/common/db_worker_test.cljs @@ -166,3 +166,65 @@ (is (= default-ids invalid-created-after-ids)) (is (= default-ids invalid-updated-after-ids))))) +(deftest test-list-tasks-contract + (let [db0 (create-test-db) + visible-id (->> (d/q '[:find [?e ...] + :in $ ?title + :where [?e :block/title ?title]] + db0 "Visible Page") + first) + late-id (->> (d/q '[:find [?e ...] + :in $ ?title + :where [?e :block/title ?title]] + db0 "Late Page") + first) + db1 (d/db-with db0 [[:db/add visible-id :block/tags :logseq.class/Task] + [:db/add visible-id :logseq.property/status :logseq.property/status.todo] + [:db/add visible-id :logseq.property/priority :logseq.property/priority.high] + [:db/add visible-id :logseq.property/scheduled "2026-02-10T08:00:00.000Z"] + [:db/add visible-id :logseq.property/deadline "2026-02-12T18:00:00.000Z"] + [:db/add late-id :block/tags :logseq.class/Task] + [:db/add late-id :logseq.property/status :logseq.property/status.done] + [:db/add late-id :logseq.property/priority :logseq.property/priority.low] + [:db/add -1 :block/title "Task Block Alpha"] + [:db/add -1 :block/page visible-id] + [:db/add -1 :block/parent visible-id] + [:db/add -1 :block/order "a"] + [:db/add -1 :block/created-at 7000] + [:db/add -1 :block/updated-at 7100] + [:db/add -1 :block/tags :logseq.class/Task] + [:db/add -1 :logseq.property/status :logseq.property/status.doing] + [:db/add -1 :logseq.property/priority :logseq.property/priority.high]]) + task-block-id (->> (d/q '[:find [?e ...] + :in $ ?title + :where + [?e :block/title ?title] + [?e :block/page]] + db1 "Task Block Alpha") + first) + all-ids (list-item-ids (cli-db-worker/list-tasks db1 {})) + status-ids (list-item-ids (cli-db-worker/list-tasks db1 {:status :logseq.property/status.todo})) + priority-ids (list-item-ids (cli-db-worker/list-tasks db1 {:priority :logseq.property/priority.high})) + content-ids (list-item-ids (cli-db-worker/list-tasks db1 {:content "aLpHa"})) + combined-ids (list-item-ids (cli-db-worker/list-tasks db1 {:status :logseq.property/status.doing + :priority :logseq.property/priority.high + :content "alpha"})) + visible-task (->> (cli-db-worker/list-tasks db1 {}) + (filter #(= visible-id (:db/id %))) + first)] + (testing "task list includes task pages and task blocks" + (is (contains? all-ids visible-id)) + (is (contains? all-ids late-id)) + (is (contains? all-ids task-block-id))) + + (testing "task filters apply status, priority, and content constraints" + (is (= #{visible-id} status-ids)) + (is (= #{visible-id task-block-id} priority-ids)) + (is (= #{task-block-id} content-ids)) + (is (= #{task-block-id} combined-ids))) + + (testing "task rows include task property fields" + (is (= :logseq.property/status.todo (:logseq.property/status visible-task))) + (is (= :logseq.property/priority.high (:logseq.property/priority visible-task))) + (is (= "2026-02-10T08:00:00.000Z" (:logseq.property/scheduled visible-task))) + (is (= "2026-02-12T18:00:00.000Z" (:logseq.property/deadline visible-task)))))) diff --git a/src/test/logseq/cli/format_test.cljs b/src/test/logseq/cli/format_test.cljs index 91c666991d..2b775455f8 100644 --- a/src/test/logseq/cli/format_test.cljs +++ b/src/test/logseq/cli/format_test.cljs @@ -246,6 +246,26 @@ "Count: 2") result))))) +(deftest test-human-output-list-task + (let [result (format/format-result {:status :ok + :command :list-task + :data {:items [{:db/id 12 + :block/title "Alpha task" + :logseq.property/status :logseq.property/status.todo + :logseq.property/priority :logseq.property/priority.high + :logseq.property/scheduled "2026-02-10T08:00:00.000Z" + :logseq.property/deadline "2026-02-12T18:00:00.000Z" + :block/created-at 40000 + :block/updated-at 90000}]}} + {:output-format nil + :now-ms 100000})] + (is (string/includes? result "STATUS")) + (is (string/includes? result "PRIORITY")) + (is (string/includes? result "SCHEDULED")) + (is (string/includes? result "DEADLINE")) + (is (string/includes? result "Alpha task")) + (is (string/includes? result "Count: 1")))) + (deftest test-human-output-search (testing "search block renders the list table contract" (let [result (format/format-result {:status :ok @@ -319,102 +339,104 @@ (is (= "logseq.class/Tag" (get-in parsed-json [:data :item :db/ident]))) (is (= "2a847b91-1565-49cc-9f9f-0f6ee25ca0f3" (get-in parsed-json [:data :item :block/uuid]))))) -(deftest test-human-output-add-upsert-remove - (testing "upsert block renders ids in two lines" - (let [result (format/format-result {:status :ok - :command :upsert-block - :context {:repo "demo-repo" - :blocks ["a" "b"]} - :data {:result [201 202]}} - {:output-format nil})] - (is (= "Upserted blocks:\n[201 202]" result)))) +(deftest test-human-output-upsert-success-lines + (doseq [[label payload expected] + [["upsert block renders ids in two lines" + {:status :ok + :command :upsert-block + :context {:repo "demo-repo" + :blocks ["a" "b"]} + :data {:result [201 202]}} + "Upserted blocks:\n[201 202]"] + ["upsert page renders ids in two lines" + {:status :ok + :command :upsert-page + :context {:repo "demo-repo" + :page "Home"} + :data {:result [123]}} + "Upserted page:\n[123]"] + ["upsert tag renders ids in two lines" + {:status :ok + :command :upsert-tag + :context {:repo "demo-repo" + :name "Quote"} + :data {:result [321]}} + "Upserted tag:\n[321]"] + ["upsert property renders ids in two lines" + {:status :ok + :command :upsert-property + :context {:repo "demo-repo" + :name "owner"} + :data {:result [654]}} + "Upserted property:\n[654]"] + ["upsert task renders ids in two lines" + {:status :ok + :command :upsert-task + :context {:repo "demo-repo" + :page "Weekly Plan"} + :data {:result [987]}} + "Upserted task:\n[987]"]]] + (testing label + (let [result (format/format-result payload {:output-format nil})] + (is (= expected result)))))) - (testing "upsert page renders ids in two lines" - (let [result (format/format-result {:status :ok - :command :upsert-page - :context {:repo "demo-repo" - :page "Home"} - :data {:result [123]}} - {:output-format nil})] - (is (= "Upserted page:\n[123]" result)))) +(deftest test-human-output-remove-success-lines + (doseq [[label payload expected] + [["remove page renders a succinct success line" + {:status :ok + :command :remove-page + :context {:repo "demo-repo" + :page "Home"} + :data {:result {:ok true}}} + "Removed page: Home (repo: demo-repo)"] + ["remove block with id list renders block count" + {:status :ok + :command :remove-block + :context {:repo "demo-repo" + :ids [1 2 3]} + :data {:result {:ok true}}} + "Removed blocks: 3 (repo: demo-repo)"] + ["remove tag renders a succinct success line" + {:status :ok + :command :remove-tag + :context {:repo "demo-repo" + :name "Quote"} + :data {:result {:ok true}}} + "Removed tag: Quote (repo: demo-repo)"] + ["remove property renders a succinct success line" + {:status :ok + :command :remove-property + :context {:repo "demo-repo" + :name "owner"} + :data {:result {:ok true}}} + "Removed property: owner (repo: demo-repo)"]]] + (testing label + (let [result (format/format-result payload {:output-format nil})] + (is (= expected result)))))) - (testing "upsert tag renders ids in two lines" - (let [result (format/format-result {:status :ok - :command :upsert-tag - :context {:repo "demo-repo" - :name "Quote"} - :data {:result [321]}} - {:output-format nil})] - (is (= "Upserted tag:\n[321]" result)))) - - (testing "upsert property renders ids in two lines" - (let [result (format/format-result {:status :ok - :command :upsert-property - :context {:repo "demo-repo" - :name "owner"} - :data {:result [654]}} - {:output-format nil})] - (is (= "Upserted property:\n[654]" result)))) - - (testing "remove page renders a succinct success line" - (let [result (format/format-result {:status :ok - :command :remove-page - :context {:repo "demo-repo" - :page "Home"} - :data {:result {:ok true}}} - {:output-format nil})] - (is (= "Removed page: Home (repo: demo-repo)" result)))) - - (testing "remove block with id list renders block count" - (let [result (format/format-result {:status :ok - :command :remove-block - :context {:repo "demo-repo" - :ids [1 2 3]} - :data {:result {:ok true}}} - {:output-format nil})] - (is (= "Removed blocks: 3 (repo: demo-repo)" result)))) - - (testing "remove tag renders a succinct success line" - (let [result (format/format-result {:status :ok - :command :remove-tag - :context {:repo "demo-repo" - :name "Quote"} - :data {:result {:ok true}}} - {:output-format nil})] - (is (= "Removed tag: Quote (repo: demo-repo)" result)))) - - (testing "remove property renders a succinct success line" - (let [result (format/format-result {:status :ok - :command :remove-property - :context {:repo "demo-repo" - :name "owner"} - :data {:result {:ok true}}} - {:output-format nil})] - (is (= "Removed property: owner (repo: demo-repo)" result)))) - - (testing "upsert block update mode renders a succinct success line" - (let [result (format/format-result {:status :ok - :command :upsert-block - :context {:repo "demo-repo" - :source "source-uuid" - :target "target-uuid" - :update-tags ["TagA"] - :update-properties {:logseq.property/publishing-public? true} - :remove-tags ["TagB"] - :remove-properties [:logseq.property/deadline]} - :data {:result {:ok true}}} - {:output-format nil})] - (is (= "Upserted block: source-uuid -> target-uuid (repo: demo-repo, tags:+1, properties:+1, remove-tags:+1, remove-properties:+1)" result)))) - - (testing "upsert block update without move target renders a succinct success line" - (let [result (format/format-result {:status :ok - :command :upsert-block - :context {:repo "demo-repo" - :source "source-uuid" - :update-tags ["TagA"]} - :data {:result {:ok true}}} - {:output-format nil})] - (is (= "Upserted block: source-uuid (repo: demo-repo, tags:+1)" result))))) +(deftest test-human-output-upsert-block-update-summary + (doseq [[label context expected] + [["upsert block update mode renders a succinct success line" + {:repo "demo-repo" + :source "source-uuid" + :target "target-uuid" + :update-tags ["TagA"] + :update-properties {:logseq.property/publishing-public? true} + :remove-tags ["TagB"] + :remove-properties [:logseq.property/deadline]} + "Upserted block: source-uuid -> target-uuid (repo: demo-repo, tags:+1, properties:+1, remove-tags:+1, remove-properties:+1)"] + ["upsert block update without move target renders a succinct success line" + {:repo "demo-repo" + :source "source-uuid" + :update-tags ["TagA"]} + "Upserted block: source-uuid (repo: demo-repo, tags:+1)"]]] + (testing label + (let [result (format/format-result {:status :ok + :command :upsert-block + :context context + :data {:result {:ok true}}} + {:output-format nil})] + (is (= expected result)))))) (deftest test-human-output-graph-import-export (testing "graph export renders a succinct success line"