feat(cli): add task related cmds

This commit is contained in:
rcmerci
2026-04-07 22:39:04 +08:00
parent 0214980a95
commit 9855011e62
14 changed files with 1138 additions and 196 deletions

View File

@@ -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"]

View File

@@ -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"

View File

@@ -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.
---

View File

@@ -213,6 +213,7 @@ Inspect and edit commands:
- `list page [--expand] [--limit <n>] [--offset <n>] [--sort <field>] [--order asc|desc]` - list pages (defaults to `--sort updated-at`)
- `list tag [--expand] [--limit <n>] [--offset <n>] [--sort <field>] [--order asc|desc]` - list tags (defaults to `--sort updated-at`)
- `list property [--expand] [--limit <n>] [--offset <n>] [--sort <field>] [--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 <status>] [--priority <low|medium|high|urgent>] [--content <text>] [--fields <csv>] [--limit <n>] [--offset <n>] [--sort <field>] [--order asc|desc]` - list task nodes tagged with `#Task` (supports both pages and blocks; defaults to `--sort updated-at`)
- `upsert block --content <text> [--target-page <name>|--target-id <id>|--target-uuid <uuid>] [--pos first-child|last-child|sibling]` - create blocks; defaults to todays journal page if no target is given
- `upsert block --blocks <edn> [--target-page <name>|--target-id <id>|--target-uuid <uuid>] [--pos first-child|last-child|sibling]` - insert blocks via EDN vector
- `upsert block --blocks-file <path> [--target-page <name>|--target-id <id>|--target-uuid <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 <name> [--update-tags <edn-vector>] [--update-properties <edn-map>] [--remove-tags <edn-vector>] [--remove-properties <edn-vector>]` - create (or update by page name) a page
- `upsert page --id <id> [--update-tags <edn-vector>] [--update-properties <edn-map>] [--remove-tags <edn-vector>] [--remove-properties <edn-vector>]` - update a page by id (cannot be combined with `--page`)
- `upsert task --content <text> [--target-page <name>|--target-id <id>|--target-uuid <uuid>] [--pos first-child|last-child|sibling] [--status <status>] [--priority <low|medium|high|urgent>] [--update-tags <edn-vector>] [--update-properties <edn-map>] [--remove-tags <edn-vector>] [--remove-properties <edn-vector>]` - create a task block and ensure `#Task` is attached
- `upsert task --page <name> [--status <status>] [--priority <low|medium|high|urgent>] [--update-tags <edn-vector>] [--update-properties <edn-map>] [--remove-tags <edn-vector>] [--remove-properties <edn-vector>]` - create/update a task page and ensure `#Task` is attached
- `upsert task --id <id>|--uuid <uuid> [--status <status>] [--priority <low|medium|high|urgent>] [--update-tags <edn-vector>] [--update-properties <edn-map>] [--remove-tags <edn-vector>] [--remove-properties <edn-vector>]` - update an existing node and ensure `#Task` is attached
- `upsert tag --name <name>` - create or upsert a tag by name
- `upsert tag --id <id> [--name <name>]` - validate a tag by id; when `--name` is provided, rename that tag id (no-op if normalized name is unchanged)
- `upsert tag --id <id> --name <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:

View File

@@ -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]

View File

@@ -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}})))

View File

@@ -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))

View File

@@ -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])))))

View File

@@ -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)))))))

View File

@@ -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)

View File

@@ -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)))))

View File

@@ -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 [_] [])

View File

@@ -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))))))

View File

@@ -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"