diff --git a/cli-e2e/spec/non_sync_cases.edn b/cli-e2e/spec/non_sync_cases.edn index 0ac3ea5dc3..121a81c2ca 100644 --- a/cli-e2e/spec/non_sync_cases.edn +++ b/cli-e2e/spec/non_sync_cases.edn @@ -213,6 +213,38 @@ :cleanup ["{{cli}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{graph-arg}}"] :tags [:upsert :list]} + {:id "asset-upsert-and-list-json" + :setup ["{{cli}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{graph-arg}} >/dev/null" + "printf 'asset-content' > {{export-path-arg}}.png"] + :cmds ["{{cli}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json upsert asset --graph {{graph-arg}} --path {{export-path-arg}}.png --content 'Asset One' --target-page Home >/dev/null" + "{{cli}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json list asset --graph {{graph-arg}} --fields id,title,type --sort updated-at --order desc --limit 1"] + :expect {:exit 0 + :stdout-json-paths {[:status] "ok" + [:data :items 0 :block/title] "Asset One" + [:data :items 0 :node/type] "block"}} + :covers {:commands ["upsert asset" "list asset"] + :options {:global ["--config" "--graph" "--data-dir" "--output"] + :upsert ["--path" "--content" "--target-page"] + :list ["--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]} + + {:id "asset-upsert-update-json" + :setup ["{{cli}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{graph-arg}} >/dev/null" + "printf 'asset-content' > {{export-path-arg}}.png" + "{{cli}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json upsert asset --graph {{graph-arg}} --path {{export-path-arg}}.png --content 'Asset Original' --target-page Home >/dev/null"] + :cmds ["{{cli}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json upsert asset --graph {{graph-arg}} --id \"$({{cli}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"Asset Original\"]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\" --content 'Asset Updated' >/dev/null" + "{{cli}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json list asset --graph {{graph-arg}} --fields id,title --sort updated-at --order desc --limit 1"] + :expect {:exit 0 + :stdout-json-paths {[:status] "ok" + [:data :items 0 :block/title] "Asset Updated"}} + :covers {:commands ["upsert asset" "list asset"] + :options {:global ["--config" "--graph" "--data-dir" "--output"] + :upsert ["--id" "--content"] + :list ["--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]} + {: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 29b87b8090..44d13c6ce6 100644 --- a/cli-e2e/spec/non_sync_inventory.edn +++ b/cli-e2e/spec/non_sync_inventory.edn @@ -35,7 +35,8 @@ "list tag" "list property" "list task" - "list node"] + "list node" + "list asset"] :options ["--expand" "--fields" "--limit" @@ -57,6 +58,7 @@ {:commands ["upsert block" "upsert page" "upsert task" + "upsert asset" "upsert tag" "upsert property"] :options ["--id" @@ -66,6 +68,7 @@ "--target-page" "--pos" "--content" + "--path" "--blocks-file" "--status" "--priority" diff --git a/docs/agent-guide/085-logseq-cli-list-upsert-asset.md b/docs/agent-guide/085-logseq-cli-list-upsert-asset.md new file mode 100644 index 0000000000..00d1849258 --- /dev/null +++ b/docs/agent-guide/085-logseq-cli-list-upsert-asset.md @@ -0,0 +1,290 @@ +# Logseq CLI `list asset` and `upsert asset` Plan + +Goal: add first-class asset commands to `logseq-cli` with current architecture constraints: + +1. Avoid introducing a new db-worker `thread-api` unless absolutely necessary. +2. Add `list asset` to list asset nodes. +3. Add `upsert asset` to create/update asset nodes, including create-time `--path ` and shared `--content` behavior. +4. Use the product definition of asset as **a node tagged with `#Asset`** (`:logseq.class/Asset`). + +Architecture direction: keep the existing `CLI -> command parse/build -> transport/invoke -> db-worker-node` pipeline, and reuse existing `list node` and `upsert block` flows as much as possible. + +Related files: +- `/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/commands.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/frontend/worker/db_core.cljs` +- `/Users/rcmerci/gh-repos/logseq/src/main/frontend/handler/editor.cljs` +- `/Users/rcmerci/gh-repos/logseq/deps/db/src/logseq/db/frontend/class.cljs` +- `/Users/rcmerci/gh-repos/logseq/deps/db/src/logseq/db/frontend/asset.cljs` +- `/Users/rcmerci/gh-repos/logseq/docs/cli/logseq-cli.md` + +--- + +## Current baseline (from codebase) + +### 1) Listing path is already sufficient for asset filtering + +- `list node` already resolves tag/property selectors and invokes `:thread-api/cli-list-nodes`. +- db-worker already supports node filtering by tag IDs via `logseq.cli.common.db-worker/list-nodes`. +- `:thread-api/cli-list-nodes` already exists in `frontend.worker.db_core`. + +Implication: `list asset` can be implemented as a thin wrapper over existing `list node` behavior by injecting the fixed `:logseq.class/Asset` selector. No new thread API required. + +### 2) Upsert path already has reusable create/update building blocks + +- `upsert block` already supports create/update mode split, target placement options, and `--content` semantics. +- Existing helpers already resolve IDs/UUIDs, run outliner ops, and handle error normalization. + +Implication: `upsert asset` should reuse upsert/add internals, while adding asset-specific validation and metadata/file handling. + +### 3) Desktop/web app asset behavior to align with + +- `:logseq.class/Asset` exists as built-in class (`deps/db/.../class.cljs`). +- Desktop creation (`new-asset-block`) sets: + - `:block/tags #{(:db/id (db/entity :logseq.class/Asset))}` + - `:logseq.property.asset/type` + - `:logseq.property.asset/size` + - `:logseq.property.asset/checksum` + - optional title override +- Asset type/title helpers exist in `logseq.db.frontend.asset` (`asset-path->type`, `asset-name->title`). + +Implication: CLI create flow should follow the same shape for metadata/tagging and content/title semantics. + +--- + +## Proposed command contracts + +## `list asset` + +### Syntax + +```text +logseq list asset --graph [options] +``` + +### Semantics + +- Returns nodes tagged with `#Asset` (`:logseq.class/Asset`). +- Uses existing list-node backend filtering path. + +### Options (aligned with current list family) + +- `--fields ` +- `--limit ` +- `--offset ` +- `--sort ` (default `updated-at`) +- `--order asc|desc` +- `--expand` + +### Initial field map + +Keep MVP aligned with `list node`: + +- `id` -> `:db/id` +- `title` -> `:block/title` +- `type` -> `:node/type` +- `page-id` -> `:block/page-id` +- `page-title` -> `:block/page-title` +- `created-at` -> `:block/created-at` +- `updated-at` -> `:block/updated-at` + +--- + +## `upsert asset` + +### Syntax + +Create mode: + +```text +logseq upsert asset --graph --path [--content ] [--target-page |--target-id |--target-uuid ] [--pos first-child|last-child|sibling] +``` + +Update mode: + +```text +logseq upsert asset --graph (--id |--uuid ) [--content ] +``` + +### Mode rules + +- `--id` or `--uuid` => update mode. +- otherwise => create mode. + +### Required/allowed options + +- `--path` is supported in create mode and required for create mode MVP. +- `--content` is supported in both create and update modes. +- In create mode, target options and `--pos` follow existing upsert block behavior. +- In update mode, `--path`, target options, and `--pos` are invalid. + +### Asset definition and validation + +- Asset identity is tag-based: node must include `#Asset` (`:logseq.class/Asset`). +- Update mode must verify target node is asset-tagged; otherwise return typed mismatch error. + +### Create-time metadata behavior + +Given `--path`: + +- derive `asset-type` from extension (same as `asset-path->type`) +- read file size from filesystem +- compute SHA-256 checksum +- ensure created node has `#Asset` tag and metadata: + - `:logseq.property.asset/type` + - `:logseq.property.asset/size` + - `:logseq.property.asset/checksum` +- copy file into graph assets directory as `.` + +### `--content` behavior + +- create mode: sets block title (same semantics as regular block upsert content) +- update mode: rewrites block title +- if create mode omits `--content`, default title comes from file basename (without extension), matching desktop behavior + +--- + +## Key design decisions + +1. **No new thread-api in MVP** + - `list asset` uses current `:thread-api/cli-list-nodes`. + - `upsert asset` uses current pull/apply-outliner APIs and CLI-side filesystem handling. + +2. **Thin list command implementation** + - Implement `list asset` by reusing list-node execution with fixed asset tag selector. + +3. **CLI-side file copy for `--path`** + - Use existing Node runtime capabilities in CLI layer (`fs`, `path`, `crypto`) and existing repo/data-dir helpers. + - Avoid adding db-worker API solely for file copy. + +4. **Asset identity remains tag-first** + - `#Asset` is the canonical selector for list/upsert identity checks. + +--- + +## Implementation plan (phased) + +### Phase 1: parser and contract tests (RED) + +1. Add parser tests for `list asset` command recognition and options. +2. Add parser tests for `upsert asset` create/update forms. +3. Add validation tests: + - create mode requires `--path` + - update mode rejects `--path` + - update mode allows `--content` + - create/update mutual exclusion errors are clear. +4. Add help/summary tests to ensure `list asset` and `upsert asset` appear consistently. + +Primary file: +- `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs` + +### Phase 2: implement `list asset` + +1. Add `list-asset` command spec and entry in `command/list.cljs`. +2. Reuse list-node field map/options and execute path with fixed asset tag selector. +3. Wire `:list-asset` in `commands.cljs` validation/build/execute branches. +4. Add formatter branch in `format.cljs`, reusing node table layout. + +Primary files: +- `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/list.cljs` +- `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/commands.cljs` +- `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/format.cljs` + +### Phase 3: implement `upsert asset` + +1. Add `upsert-asset` option spec + command entry in `command/upsert.cljs`. +2. Implement build logic: + - mode detection by id/uuid + - create-mode path validation + - update-mode selector validation +3. Implement execute logic: + - create mode: + - prepare metadata from `--path` + - create block (reusing add/upsert block path) + - ensure `#Asset` tag + required asset properties + - copy file into graph `assets/` + - update mode: + - resolve node by id/uuid + - enforce asset tag membership + - apply content update when provided +4. Keep all DB writes via existing thread-api calls. + +Primary files: +- `/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/format.cljs` + +### Phase 4: tests and docs/e2e + +1. Add/extend unit tests for upsert asset builder/executor behavior. +2. Add format tests for list/upsert asset human output. +3. Update CLI docs and command reference. +4. Update CLI e2e inventory and add non-sync cases: + - `upsert asset --path ...` create + - `upsert asset --id|--uuid --content ...` update + - `list asset` returns asset nodes + +Primary files: +- `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs` +- `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/command/upsert_test.cljs` +- `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/format_test.cljs` +- `/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_cases.edn` + +--- + +## Verification plan + +Focused tests: + +```bash +bb dev:test -v logseq.cli.commands-test +bb dev:test -v logseq.cli.command.upsert-test +bb dev:test -v logseq.cli.format-test +``` + +CLI e2e: + +```bash +bb -f cli-e2e/bb.edn test --skip-build +``` + +Full regression: + +```bash +bb dev:lint-and-test +``` + +--- + +## Acceptance criteria + +1. `logseq list asset` is available and lists nodes tagged `#Asset`. +2. `list asset` supports list-family options consistent with current commands. +3. `logseq upsert asset` exists with create/update modes. +4. `upsert asset` create mode supports `--path `. +5. `--content` works in both create and update modes. +6. Update mode enforces that target node is an asset (`#Asset`). +7. MVP introduces no new db-worker thread-api unless a hard blocker is discovered. +8. Unit tests + CLI e2e coverage are updated and passing. + +--- + +## Risks and mitigations + +- **Risk:** create flow can leave DB/file inconsistencies on partial failure. + - **Mitigation:** implement best-effort rollback/cleanup order and test failure paths. + +- **Risk:** internal asset properties are not public and bypassing generic property parsers may be required. + - **Mitigation:** use dedicated asset metadata writing path with explicit property identifiers, not generic user property input. + +- **Risk:** ambiguity around whether create mode should allow asset node creation without `--path`. + - **Mitigation:** lock MVP contract to require `--path` in create mode; broaden later only if product needs it. + +--- + +No blocking open question for MVP. \ No newline at end of file diff --git a/docs/cli/logseq-cli.md b/docs/cli/logseq-cli.md index add7a41d1f..11ebc98c6a 100644 --- a/docs/cli/logseq-cli.md +++ b/docs/cli/logseq-cli.md @@ -237,6 +237,7 @@ Inspect and edit commands: - `list node [--tags ] [--properties ] [--fields ] [--limit ] [--offset ] [--sort ] [--order asc|desc]` - list ordinary nodes (pages and blocks) filtered by tags/properties (supports selector forms id/uuid/ident/name; at least one of `--tags` or `--properties` is required; defaults to `--sort updated-at`) - `--tags` and `--properties` use **all-of** semantics, and when both are present they are combined with **AND**. - CSV tokens are trimmed and empty tokens are ignored; if a provided filter becomes empty after normalization, CLI returns `invalid-options`. +- `list asset [--fields ] [--limit ] [--offset ] [--sort ] [--order asc|desc]` - list nodes tagged with `#Asset` (`:logseq.class/Asset`; 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 @@ -250,6 +251,9 @@ Inspect and edit commands: - `--status` is validated at runtime using values from the current graph; invalid values return an error that includes available values from that graph. - generic task tag/property mutation options are not supported on `upsert task`; use `upsert block --update-tags/--update-properties/--remove-tags/--remove-properties` when needed. - for the same field, set and clear options are mutually exclusive (for example: `--status todo --no-status` is invalid). +- `upsert asset --path [--content ] [--target-page |--target-id |--target-uuid ] [--pos first-child|last-child|sibling]` - create an asset node, add `#Asset`, set asset metadata (`type`, `size`, `checksum`), and copy the local file into graph `assets/` as `.` +- `upsert asset --id |--uuid [--content ]` - update an existing asset node title; target node must be tagged with `#Asset` + - create mode requires `--path`; update mode rejects `--path`. - `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 @@ -274,13 +278,15 @@ Help output: Subcommands: list page [options] List pages list tag [options] List tags - list property [options] List properties - list task [options] List tasks - list node [options] List nodes - upsert block [options] Upsert block - upsert page [options] Upsert page - upsert task [options] Upsert task - upsert tag [options] Upsert tag + list property [options] List properties + list task [options] List tasks + list node [options] List nodes + list asset [options] List assets + upsert block [options] Upsert block + upsert page [options] Upsert page + upsert task [options] Upsert task + upsert asset [options] Upsert asset + upsert tag [options] Upsert tag upsert property [options] Upsert property move [options] Move block remove [options] Remove block or page @@ -315,7 +321,7 @@ Output formats: 139ms └── cli.execute-action 129ms └── transport.invoke:thread-api/cli-list-pages ``` -- Human output is plain text. List/search commands render tables with a final `Count: N` line. For list and search subcommands, the ID column uses `:db/id` (not UUID). If `:db/ident` exists, an `IDENT` column is included. `list property` includes dedicated `TYPE` and `CARDINALITY` columns; `list node` includes a dedicated `TYPE` column (page/block) and page context columns for blocks. Search table columns are `ID` and `TITLE`. For `list page|tag|property|task|node` in human output, the `TITLE` column is display-width-aware (CJK-safe), defaults to max width `40`, and truncates overflow with `…`; set `:list-title-max-display-width` in `cli.edn` to override. JSON/EDN outputs keep full titles (no truncation). Block titles can include multiple lines; multi-line rows align additional lines under the `TITLE` column. Times such as list `UPDATED-AT`/`CREATED-AT` and `graph info` `Created at` are shown in human-friendly relative form. Errors include error codes and may include a `Hint:` line. Use `--output json|edn` for structured output. +- Human output is plain text. List/search commands render tables with a final `Count: N` line. For list and search subcommands, the ID column uses `:db/id` (not UUID). If `:db/ident` exists, an `IDENT` column is included. `list property` includes dedicated `TYPE` and `CARDINALITY` columns; `list node`/`list asset` include a dedicated `TYPE` column (page/block) and page context columns for blocks. Search table columns are `ID` and `TITLE`. For `list page|tag|property|task|node|asset` in human output, the `TITLE` column is display-width-aware (CJK-safe), defaults to max width `40`, and truncates overflow with `…`; set `:list-title-max-display-width` in `cli.edn` to override. JSON/EDN outputs keep full titles (no truncation). Block titles can include multiple lines; multi-line rows align additional lines under the `TITLE` column. Times such as list `UPDATED-AT`/`CREATED-AT` and `graph info` `Created at` are shown in human-friendly relative form. Errors include error codes and may include a `Hint:` line. Use `--output json|edn` for structured output. - `example` human output includes `Selector`, `Matched commands`, and `Examples` sections. Structured output (`json`/`edn`) includes `selector`, `matched-commands`, `examples`, and `message` fields under `data`. - `skill show` always prints raw markdown text to stdout, regardless of `--output` mode. - `sync download` progress lines are streamed to stdout only when progress is enabled. In `json`/`edn` mode, progress is disabled by default unless `--progress true` is provided. @@ -339,7 +345,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`, `upsert block`, and `upsert task` return entity ids in `data.result` for JSON/EDN output, and include ids in human output. +- `upsert page`, `upsert block`, `upsert task`, and `upsert asset` 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/logseq/cli/command/list.cljs b/src/main/logseq/cli/command/list.cljs index 7ab0dbf09c..af595b2a0e 100644 --- a/src/main/logseq/cli/command/list.cljs +++ b/src/main/logseq/cli/command/list.cljs @@ -174,6 +174,16 @@ :sort {:validate (set (keys list-node-field-map))} :fields {:multiple-values (keys list-node-field-map)}})) +(def ^:private list-asset-field-map + list-node-field-map) + +(def ^:private list-asset-spec + (merge-with + merge + list-common-spec + {:sort {:validate (set (keys list-asset-field-map))} + :fields {:multiple-values (keys list-asset-field-map)}})) + (def entries [(core/command-entry ["list" "page"] :list-page "List pages" list-page-spec {:examples ["logseq list page --graph my-graph" @@ -190,7 +200,10 @@ "logseq list task --graph my-graph --content \"release\" --sort updated-at --order desc"]}) (core/command-entry ["list" "node"] :list-node "List nodes" list-node-spec {:examples ["logseq list node --graph my-graph --tags project,work" - "logseq list node --graph my-graph --properties status,priority --sort updated-at --order desc"]})]) + "logseq list node --graph my-graph --properties status,priority --sort updated-at --order desc"]}) + (core/command-entry ["list" "asset"] :list-asset "List assets" list-asset-spec + {:examples ["logseq list asset --graph my-graph" + "logseq list asset --graph my-graph --limit 20 --sort updated-at --order desc"]})]) (defn- parse-csv-option [value] @@ -404,6 +417,35 @@ {:status :ok :data {:items final}}))) +(def ^:private asset-tag-ident + :logseq.class/Asset) + +(defn- ensure-asset-tag-id! + [config repo] + (p/let [entity (transport/invoke config :thread-api/pull false + [repo [:db/id] [:db/ident asset-tag-ident]])] + (if-let [tag-id (:db/id entity)] + tag-id + (throw (ex-info "asset tag not found" + {:code :asset-tag-not-found}))))) + +(defn execute-list-asset + [action config] + (-> (p/let [cfg (cli-server/ensure-server! config (:repo action)) + options (:options action) + asset-tag-id (ensure-asset-tag-id! cfg (:repo action)) + worker-options (assoc options :tag-ids [asset-tag-id]) + items (transport/invoke cfg :thread-api/cli-list-nodes false + [(:repo action) worker-options]) + sort-field (effective-sort-field options) + order (or (:order options) "asc") + fields (parse-field-list (:fields options)) + sorted (apply-sort items sort-field order list-asset-field-map) + limited (apply-offset-limit sorted (:offset options) (:limit options)) + final (apply-fields limited fields list-asset-field-map)] + {:status :ok + :data {:items final}}))) + (defn- normalize-priority [value] (let [text (some-> value string/trim string/lower-case)] diff --git a/src/main/logseq/cli/command/upsert.cljs b/src/main/logseq/cli/command/upsert.cljs index 6ff76e23b8..1214bbd98d 100644 --- a/src/main/logseq/cli/command/upsert.cljs +++ b/src/main/logseq/cli/command/upsert.cljs @@ -1,6 +1,9 @@ (ns logseq.cli.command.upsert "Upsert-related CLI commands." - (:require [clojure.string :as string] + (:require ["crypto" :as crypto] + ["fs" :as fs] + ["path" :as node-path] + [clojure.string :as string] [logseq.cli.command.add :as add-command] [logseq.cli.command.core :as core] [logseq.cli.command.task-status :as task-status-command] @@ -8,8 +11,10 @@ [logseq.cli.server :as cli-server] [logseq.cli.transport :as transport] [logseq.common.graph :as common-graph] + [logseq.common.graph-dir :as graph-dir] [logseq.common.util :as common-util] [logseq.db :as ldb] + [logseq.db.frontend.asset :as db-asset] [logseq.db.frontend.property.type :as db-property-type] [promesa.core :as p] [logseq.db.frontend.property :as db-property])) @@ -84,6 +89,27 @@ :no-deadline {:desc "Clear task deadline datetime" :coerce :boolean}}) +(def ^:private upsert-asset-spec + {:id {:desc "Target asset node db/id (forces update mode) [update only]" + :coerce :long} + :uuid {:desc "Target asset node UUID (forces update mode) [update only]" + :validate {:pred (comp parse-uuid str) + :ex-msg (constantly "Option uuid must be a valid UUID string")}} + :path {:desc "Asset file path [create only]" + :coerce common-graph/expand-home + :complete :file} + :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"}} + :content {:alias :c + :desc "Asset title (create/update)"}}) + (def ^:private upsert-tag-spec {:id {:desc "Target tag db/id (forces update mode)" :coerce :long} @@ -119,6 +145,9 @@ {:examples ["logseq upsert task --graph my-graph --content \"Ship release\" --target-page Home --status todo --priority high --scheduled \"2026-02-10T08:00:00.000Z\" --deadline \"2026-02-12T18:00:00.000Z\"" "logseq upsert task --graph my-graph --page Weekly Plan --status doing" "logseq upsert task --graph my-graph --id 123 --no-status --no-priority"]}) + (core/command-entry ["upsert" "asset"] :upsert-asset "Upsert asset" upsert-asset-spec + {:examples ["logseq upsert asset --graph my-graph --path ./assets/logo.png --target-page Home" + "logseq upsert asset --graph my-graph --id 123 --content \"Updated asset title\""]}) (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"]}) @@ -174,7 +203,7 @@ ". Available values: " (string/join ", " available-priority-values))) -(defn invalid-options? +(defn ^:large-vars/cleanup-todo invalid-options? [command opts] (case command :upsert-block @@ -260,6 +289,42 @@ :else nil)) + :upsert-asset + (let [id (:id opts) + uuid (some-> (:uuid opts) string/trim) + path (some-> (:path opts) str string/trim) + target-page (some-> (:target-page opts) string/trim) + selectors (filter some? [id uuid]) + target-selectors (filter some? [(:target-id opts) + (:target-uuid opts) + target-page]) + pos (some-> (:pos opts) string/trim string/lower-case) + update-mode? (or (some? id) (seq uuid))] + (cond + (> (count selectors) 1) + "only one of --id or --uuid is allowed" + + (and update-mode? (seq path)) + "--path is only valid in create mode" + + (and (not update-mode?) (not (seq path))) + "--path is required in create mode" + + (> (count target-selectors) 1) + "only one of --target-id, --target-uuid, or --target-page is allowed" + + (and update-mode? (or (seq target-selectors) (seq pos))) + "--target-* and --pos are only valid in create mode" + + (and (seq pos) (empty? target-selectors)) + "--pos is only valid when a target option is provided" + + (and (= pos "sibling") (seq target-page)) + "--pos sibling is only valid for block targets" + + :else + nil)) + :upsert-tag (let [name-provided? (contains? opts :name) name (normalize-tag-name (:name opts))] @@ -483,6 +548,53 @@ (seq status-text) (assoc :status-input status-text) (and (seq content) (not= mode :page)) (assoc :content content))})))) +(defn build-asset-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) + content (some-> (:content options) string/trim) + path (some-> (:path options) str string/trim) + asset-update-mode? (or (some? id) (seq uuid)) + invalid-message (invalid-options? :upsert-asset options)] + (cond + (seq invalid-message) + {:ok? false + :error {:code :invalid-options + :message invalid-message}} + + asset-update-mode? + {:ok? true + :action (cond-> {:type :upsert-asset + :mode :update + :repo repo + :graph (core/repo->graph repo)} + (some? id) (assoc :id id) + (seq uuid) (assoc :uuid uuid) + (seq content) (assoc :content content))} + + :else + (let [default-title (db-asset/asset-name->title (node-path/basename path)) + create-content (or content default-title) + create-options (cond-> (assoc options :content create-content) + (seq (:target-page options)) + (assoc :target-page-name (:target-page options)) + true + (dissoc :target-page :path)) + create-result (add-command/build-add-block-action create-options [] repo)] + (if (:ok? create-result) + (-> create-result + (update :action + (fn [action] + (-> action + (assoc :type :upsert-asset + :mode :create + :asset-path path))))) + create-result)))))) + (defn build-tag-action [options repo] (if-not (seq repo) @@ -741,6 +853,13 @@ (def ^:private task-tag-ident :logseq.class/Task) +(def ^:private asset-selector + [:db/id :block/uuid :block/title :logseq.property/deleted-at + {:block/tags [:db/ident]}]) + +(def ^:private asset-tag-ident + :logseq.class/Asset) + (defn- normalize-lookup-uuid [value] (cond @@ -773,6 +892,38 @@ :id id :uuid uuid}))))) +(defn- asset-entity? + [entity] + (some #(= asset-tag-ident (:db/ident %)) + (:block/tags entity))) + +(defn- ensure-asset-node! + [config repo {:keys [id uuid]}] + (p/let [entity (cond + (some? id) + (pull-entity-by-id config repo asset-selector id) + + (seq uuid) + (pull-entity-by-uuid config repo asset-selector uuid) + + :else + nil)] + (cond + (or (not (:db/id entity)) (ldb/recycled? entity)) + (throw (ex-info "asset not found for selector" + {:code upsert-id-not-found-code + :id id + :uuid uuid})) + + (not (asset-entity? entity)) + (throw (ex-info "selector must be a node tagged with #Asset" + {:code upsert-id-type-mismatch-code + :id id + :uuid uuid})) + + :else + entity))) + (defn- ensure-task-tag-id! [config repo] (p/let [entity (transport/invoke config :thread-api/pull false @@ -971,6 +1122,134 @@ :error {:code (or (get-in (ex-data e) [:code]) :exception) :message (or (ex-message e) (str e))}})))) +(defn- asset-file-exists? + [path] + (and (seq path) + (fs/existsSync path))) + +(defn- asset-file-size-bytes + [path] + (let [stat (fs/statSync path)] + (.-size stat))) + +(defn- asset-file-checksum + [path] + (-> (.createHash crypto "sha256") + (.update (fs/readFileSync path)) + (.digest "hex"))) + +(defn- ensure-dir! + [path] + (fs/mkdirSync path #js {:recursive true})) + +(defn- copy-file! + [source destination] + (fs/copyFileSync source destination)) + +(defn- graph-assets-dir-path + [config repo] + (if-let [graph-dir-name (graph-dir/repo->encoded-graph-dir-name repo)] + (node-path/join (cli-server/resolve-data-dir config) + graph-dir-name + "assets") + (throw (ex-info "invalid repo" + {:code :invalid-repo + :repo repo})))) + +(defn- ensure-asset-file-path! + [path] + (when-not (asset-file-exists? path) + (throw (ex-info "asset file not found" + {:code :asset-file-not-found + :path path})))) + +(defn- read-asset-file-metadata + [path] + (let [asset-type (db-asset/asset-path->type path)] + (when-not (seq asset-type) + (throw (ex-info "asset path must include a file extension" + {:code :invalid-options + :path path}))) + {:asset/type asset-type + :asset/size (asset-file-size-bytes path) + :asset/checksum (asset-file-checksum path)})) + +(defn- ensure-asset-tag-id! + [config repo] + (p/let [entity (transport/invoke config :thread-api/pull false + [repo [:db/id] [:db/ident asset-tag-ident]])] + (if-let [tag-id (:db/id entity)] + tag-id + (throw (ex-info "asset tag not found" + {:code :asset-tag-not-found}))))) + +(defn- copy-asset-file-to-graph! + [config repo block-uuid asset-type source-path] + (let [assets-dir (graph-assets-dir-path config repo) + destination-path (node-path/join assets-dir (str block-uuid "." asset-type))] + (ensure-dir! assets-dir) + (copy-file! source-path destination-path) + destination-path)) + +(defn execute-upsert-asset + [action config] + (-> (p/let [cfg (cli-server/ensure-server! config (:repo action))] + (case (:mode action) + :create + (p/let [asset-path (:asset-path action) + _ (ensure-asset-file-path! asset-path) + metadata (read-asset-file-metadata asset-path) + asset-tag-id (ensure-asset-tag-id! cfg (:repo action)) + action* (update action + :blocks + (fn [blocks] + (if (seq blocks) + (update blocks 0 merge + {:logseq.property.asset/type (:asset/type metadata) + :logseq.property.asset/size (:asset/size metadata) + :logseq.property.asset/checksum (:asset/checksum metadata) + :block/tags #{asset-tag-id}}) + blocks))) + create-result (add-command/execute-add-block (assoc action* :type :add-block) config) + created-ids (vec (or (get-in create-result [:data :result]) [])) + created-id (first created-ids) + _ (when-not (some? created-id) + (throw (ex-info "asset block not created" + {:code :asset-create-failed}))) + created-entity (pull-entity-by-id cfg (:repo action) [:db/id :block/uuid] created-id) + block-uuid (:block/uuid created-entity) + _ (when-not (uuid? block-uuid) + (throw (ex-info "created asset block missing uuid" + {:code :asset-create-failed + :id created-id}))) + _ (copy-asset-file-to-graph! config + (:repo action) + block-uuid + (:asset/type metadata) + asset-path)] + {:status :ok + :data {:result [created-id]}}) + + :update + (p/let [entity (ensure-asset-node! cfg (:repo action) action) + node-id (:db/id entity) + _ (when (seq (:content action)) + (update-command/execute-update (-> action + (assoc :type :update-block + :id node-id) + (dissoc :uuid)) + config))] + {:status :ok + :data {:result [node-id]}}) + + {:status :error + :error {:code :invalid-options + :message "invalid upsert asset 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 44603c9591..6d98e983af 100644 --- a/src/main/logseq/cli/commands.cljs +++ b/src/main/logseq/cli/commands.cljs @@ -185,13 +185,13 @@ #{:graph-create :graph-switch :graph-remove :graph-import}) (def ^:private upsert-validation-commands - #{:upsert-block :upsert-page :upsert-task :upsert-tag :upsert-property}) + #{:upsert-block :upsert-page :upsert-task :upsert-tag :upsert-property :upsert-asset}) (def ^:private search-validation-commands #{:search-block :search-page :search-property :search-tag}) (def ^:private list-validation-commands - #{:list-page :list-tag :list-property :list-task :list-node}) + #{:list-page :list-tag :list-property :list-task :list-node :list-asset}) (def ^:private remove-validation-commands #{:remove-block :remove-page :remove-tag :remove-property}) @@ -258,6 +258,9 @@ (not (seq (some-> (:name opts) string/trim)))) (missing-property-name-result summary) + (and (= command :upsert-asset) upsert-invalid-options-message) + (command-core/invalid-options-result summary upsert-invalid-options-message) + :else nil)) @@ -560,7 +563,7 @@ ;; Command-specific errors live in subcommand namespaces. -(defn build-action +(defn ^:large-vars/cleanup-todo build-action [parsed config] (if-not (:ok? parsed) parsed @@ -596,7 +599,7 @@ (:server-list :server-cleanup :server-start :server-stop :server-restart) (server-command/build-action command server-repo) - (:list-page :list-tag :list-property :list-task :list-node) + (:list-page :list-tag :list-property :list-task :list-node :list-asset) (list-command/build-action command options repo) (:search-block :search-page :search-property :search-tag) @@ -611,6 +614,9 @@ :upsert-task (upsert-command/build-task-action options repo) + :upsert-asset + (upsert-command/build-asset-action options repo) + :upsert-tag (upsert-command/build-tag-action options repo) @@ -692,6 +698,7 @@ :list-property (list-command/execute-list-property action config) :list-task (list-command/execute-list-task action config) :list-node (list-command/execute-list-node action config) + :list-asset (list-command/execute-list-asset 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) @@ -699,6 +706,7 @@ :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-asset (upsert-command/execute-upsert-asset 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) diff --git a/src/main/logseq/cli/format.cljs b/src/main/logseq/cli/format.cljs index 1833e27a42..765331697b 100644 --- a/src/main/logseq/cli/format.cljs +++ b/src/main/logseq/cli/format.cljs @@ -799,6 +799,10 @@ [_context ids] (str "Upserted property:\n" (pr-str (vec (or ids []))))) +(defn- format-upsert-asset + [_context ids] + (str "Upserted asset:\n" (pr-str (vec (or ids []))))) + (defn- format-remove-block [{:keys [repo uuid id ids]}] (cond @@ -929,11 +933,13 @@ :list-property (format-list-property (:items data) now-ms list-title-max-display-width) :list-task (format-list-task (:items data) now-ms list-title-max-display-width) :list-node (format-list-node (:items data) now-ms list-title-max-display-width) + :list-asset (format-list-node (:items data) now-ms list-title-max-display-width) (: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-asset (format-upsert-asset 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 2b00047c66..6eb5452d9b 100644 --- a/src/test/logseq/cli/command/upsert_test.cljs +++ b/src/test/logseq/cli/command/upsert_test.cljs @@ -1,6 +1,8 @@ (ns logseq.cli.command.upsert-test (:require [clojure.string :as string] [cljs.test :refer [async deftest is testing]] + [logseq.cli.command.add :as add-command] + [logseq.cli.command.update :as update-command] [logseq.cli.command.upsert :as upsert-command] [logseq.cli.server :as cli-server] [logseq.cli.transport :as transport] @@ -53,6 +55,151 @@ (is false (str "unexpected error: " e)))) (p/finally done))))) +(deftest test-build-asset-action + (testing "upsert asset create mode derives default title from path" + (let [result (upsert-command/build-asset-action {:path "/tmp/team-logo.png"} + "logseq_db_demo")] + (is (true? (:ok? result))) + (is (= :upsert-asset (get-in result [:action :type]))) + (is (= :create (get-in result [:action :mode]))) + (is (= "/tmp/team-logo.png" (get-in result [:action :asset-path]))) + (is (= "team-logo" (get-in result [:action :blocks 0 :block/title]))))) + + (testing "upsert asset update mode preserves selector and content" + (let [result (upsert-command/build-asset-action {:id 42 :content "New title"} + "logseq_db_demo")] + (is (true? (:ok? result))) + (is (= :upsert-asset (get-in result [:action :type]))) + (is (= :update (get-in result [:action :mode]))) + (is (= 42 (get-in result [:action :id]))) + (is (= "New title" (get-in result [:action :content])))))) + +(deftest test-execute-upsert-asset-create-applies-metadata-and-copies-file + (async done + (let [add-actions* (atom []) + copy-calls* (atom []) + action {:type :upsert-asset + :mode :create + :repo "demo-repo" + :graph "demo-graph" + :asset-path "/tmp/logo.png" + :content "Logo" + :blocks [{:block/title "Logo"}] }] + (-> (p/with-redefs [cli-server/ensure-server! (fn [config _repo] + (p/resolved (assoc config :base-url "http://example"))) + upsert-command/asset-file-exists? (fn [_] true) + upsert-command/asset-file-size-bytes (fn [_] 123) + upsert-command/asset-file-checksum (fn [_] "sha-256-value") + upsert-command/copy-asset-file-to-graph! (fn [_ repo block-uuid asset-type source-path] + (swap! copy-calls* conj [repo block-uuid asset-type source-path]) + "/tmp/copied/logo.png") + add-command/execute-add-block (fn [add-action _] + (swap! add-actions* conj add-action) + (p/resolved {:status :ok + :data {:result [101]}})) + transport/invoke (fn [_ method _ args] + (case method + :thread-api/pull + (let [[_ _ lookup] args] + (cond + (= lookup 101) + (p/resolved {:db/id 101 + :block/uuid (uuid "00000000-0000-0000-0000-000000000101")}) + + (= lookup [:db/ident :logseq.class/Asset]) + (p/resolved {:db/id 900}) + + :else + (p/resolved {}))) + + (throw (ex-info "unexpected invoke" + {:method method + :args args}))))] + (p/let [result (upsert-command/execute-upsert-asset action {}) + block (get-in (first @add-actions*) [:blocks 0])] + (is (= :ok (:status result))) + (is (= [101] (get-in result [:data :result]))) + (is (= "png" (:logseq.property.asset/type block))) + (is (= 123 (:logseq.property.asset/size block))) + (is (= "sha-256-value" (:logseq.property.asset/checksum block))) + (is (= #{900} (:block/tags block))) + (is (= [["demo-repo" + (uuid "00000000-0000-0000-0000-000000000101") + "png" + "/tmp/logo.png"]] + @copy-calls*)))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally done))))) + +(deftest test-execute-upsert-asset-update + (async done + (let [update-calls* (atom []) + action {:type :upsert-asset + :mode :update + :repo "demo-repo" + :graph "demo-graph" + :id 42 + :content "Updated title"}] + (-> (p/with-redefs [cli-server/ensure-server! (fn [config _repo] + (p/resolved (assoc config :base-url "http://example"))) + update-command/execute-update (fn [update-action _] + (swap! update-calls* conj update-action) + (p/resolved {:status :ok :data {:result nil}})) + transport/invoke (fn [_ method _ args] + (case method + :thread-api/pull + (let [[_ _ lookup] args] + (if (= lookup 42) + (p/resolved {:db/id 42 + :block/uuid (uuid "00000000-0000-0000-0000-000000000042") + :block/tags [{:db/ident :logseq.class/Asset}]}) + (p/resolved {}))) + + (throw (ex-info "unexpected invoke" + {:method method + :args args}))))] + (p/let [result (upsert-command/execute-upsert-asset action {})] + (is (= :ok (:status result))) + (is (= [42] (get-in result [:data :result]))) + (is (= :update-block (get-in (first @update-calls*) [:type]))) + (is (= 42 (get-in (first @update-calls*) [:id]))) + (is (= "Updated title" (get-in (first @update-calls*) [:content]))))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally done))))) + +(deftest test-execute-upsert-asset-update-rejects-non-asset-node + (async done + (let [action {:type :upsert-asset + :mode :update + :repo "demo-repo" + :graph "demo-graph" + :id 42}] + (-> (p/with-redefs [cli-server/ensure-server! (fn [config _repo] + (p/resolved (assoc config :base-url "http://example"))) + transport/invoke (fn [_ method _ args] + (case method + :thread-api/pull + (let [[_ _ lookup] args] + (if (= lookup 42) + (p/resolved {:db/id 42 + :block/uuid (uuid "00000000-0000-0000-0000-000000000042") + :block/tags [{:db/ident :logseq.class/Task}]}) + (p/resolved {}))) + + (throw (ex-info "unexpected invoke" + {:method method + :args args}))))] + (p/let [result (upsert-command/execute-upsert-asset action {}) + message (or (get-in result [:error :message]) "")] + (is (= :error (:status result))) + (is (= :upsert-id-type-mismatch (get-in result [:error :code]))) + (is (string/includes? message "#Asset")))) + (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")] diff --git a/src/test/logseq/cli/commands_test.cljs b/src/test/logseq/cli/commands_test.cljs index 4ab5a643e6..6c7fb639af 100644 --- a/src/test/logseq/cli/commands_test.cljs +++ b/src/test/logseq/cli/commands_test.cljs @@ -71,7 +71,9 @@ (is (string/includes? plain-summary "Graph Management")) (is (string/includes? plain-summary "Authentication")) (is (string/includes? plain-summary "list")) + (is (string/includes? plain-summary "list asset")) (is (string/includes? plain-summary "upsert")) + (is (string/includes? plain-summary "upsert asset")) (is (string/includes? plain-summary "remove")) (is (string/includes? plain-summary "query")) (is (string/includes? plain-summary "search")) @@ -95,11 +97,13 @@ (is (contains-bold? summary "list property")) (is (contains-bold? summary "list task")) (is (contains-bold? summary "list node")) + (is (contains-bold? summary "list asset")) (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 "upsert asset")) (is (contains-bold? summary "remove block")) (is (contains-bold? summary "remove page")) (is (contains-bold? summary "remove tag")) @@ -153,11 +157,11 @@ ["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 node"] - ["list page" "list tag" "list property" "list task" "list node"]] + ["list page" "list tag" "list property" "list task" "list node" "list asset"] + ["list page" "list tag" "list property" "list task" "list node" "list asset"]] ["upsert" - ["upsert task" "upsert tag" "upsert property"] - ["upsert task" "upsert tag" "upsert property"]] + ["upsert task" "upsert tag" "upsert property" "upsert asset"] + ["upsert task" "upsert tag" "upsert property" "upsert asset"]] ["server" ["server list" "server start"] ["server list" "server start"]]]] @@ -426,11 +430,13 @@ (is (string/includes? plain-summary "upsert tag")) (is (string/includes? plain-summary "upsert property")) (is (string/includes? plain-summary "upsert task")) + (is (string/includes? plain-summary "upsert asset")) (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 "upsert task")) + (is (contains-bold? summary "upsert asset"))))) (deftest test-parse-args-help-alignment (testing "graph group aligns subcommand columns" @@ -1060,7 +1066,7 @@ result (tree->text tree-data)] (is (string/includes? result (str "[[" (nth uuids 10) "]]")))))) -(deftest test-list-subcommand-parse +(deftest ^:large-vars/cleanup-todo test-list-subcommand-parse (testing "list page parses" (let [result (commands/parse-args ["list" "page" "--expand" @@ -1158,6 +1164,23 @@ (is (= 10 (get-in result [:options :limit]))) (is (= 2 (get-in result [:options :offset]))) (is (= "updated-at" (get-in result [:options :sort]))) + (is (= "desc" (get-in result [:options :order]))))) + + (testing "list asset parses with common list options" + (let [result (commands/parse-args ["list" "asset" + "--expand" + "--fields" "id,title,type,updated-at" + "--limit" "5" + "--offset" "1" + "--sort" "updated-at" + "--order" "desc"])] + (is (true? (:ok? result))) + (is (= :list-asset (:command result))) + (is (true? (get-in result [:options :expand]))) + (is (= "id,title,type,updated-at" (get-in result [:options :fields]))) + (is (= 5 (get-in result [:options :limit]))) + (is (= 1 (get-in result [:options :offset]))) + (is (= "updated-at" (get-in result [:options :sort]))) (is (= "desc" (get-in result [:options :order])))))) (deftest test-search-subcommand-parse @@ -1244,6 +1267,11 @@ (is (false? (:ok? result))) (is (= :invalid-options (get-in result [:error :code]))))) + (testing "list asset rejects invalid sort field" + (let [result (commands/parse-args ["list" "asset" "--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"]) message (or (some-> (get-in result [:error :message]) strip-ansi) "")] @@ -1360,6 +1388,40 @@ (is false (str "unexpected error: " e)))) (p/finally done)))) +(deftest test-list-asset-execute-filters-by-asset-tag + (async done + (let [calls* (atom [])] + (-> (p/with-redefs [cli-server/ensure-server! (fn [_ _] {:base-url "http://example"}) + transport/invoke (fn [_ method _ args] + (swap! calls* conj {:method method :args args}) + (case method + :thread-api/pull + {:db/id 900} + + :thread-api/cli-list-nodes + [{:db/id 2 + :block/title "asset-b" + :node/type "block" + :block/updated-at 30} + {:db/id 1 + :block/title "asset-a" + :node/type "block" + :block/updated-at 10}] + + (throw (ex-info "unexpected invoke" {:method method :args args}))))] + (p/let [result (list-command/execute-list-asset + {:repo "demo" + :options {:sort "updated-at" :order "desc" :limit 1}} + {}) + items (get-in result [:data :items]) + list-call (some #(when (= :thread-api/cli-list-nodes (:method %)) %) @calls*)] + (is (= :ok (:status result))) + (is (= [2] (mapv :db/id items))) + (is (= [900] (get-in list-call [:args 1 :tag-ids]))))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally done))))) + (deftest test-task-runtime-invalid-status-includes-graph-values (async done (let [list-calls* (atom []) @@ -1850,6 +1912,52 @@ (is (string/includes? message "Invalid value for option :priority: wat")) (is (string/includes? message "Available values: low, medium, high, urgent"))))) +(deftest test-verb-subcommand-parse-upsert-asset-mode + (testing "upsert asset create mode requires --path" + (let [result (commands/parse-args ["upsert" "asset" "--content" "Asset title"]) + message (or (some-> (get-in result [:error :message]) strip-ansi) "")] + (is (false? (:ok? result))) + (is (= :invalid-options (get-in result [:error :code]))) + (is (string/includes? message "--path is required")))) + + (testing "upsert asset create mode parses with path, target and pos" + (let [result (commands/parse-args ["upsert" "asset" + "--path" "./fixtures/image.png" + "--content" "Asset title" + "--target-page" "Home" + "--pos" "last-child"])] + (is (true? (:ok? result))) + (is (= :upsert-asset (:command result))) + (is (= "./fixtures/image.png" (get-in result [:options :path]))) + (is (= "Asset title" (get-in result [:options :content]))) + (is (= "Home" (get-in result [:options :target-page]))) + (is (= "last-child" (get-in result [:options :pos]))))) + + (testing "upsert asset update mode parses with id and content" + (let [result (commands/parse-args ["upsert" "asset" + "--id" "42" + "--content" "Updated asset title"])] + (is (true? (:ok? result))) + (is (= :upsert-asset (:command result))) + (is (= 42 (get-in result [:options :id]))) + (is (= "Updated asset title" (get-in result [:options :content]))))) + + (testing "upsert asset update mode rejects --path" + (let [result (commands/parse-args ["upsert" "asset" + "--id" "42" + "--path" "./fixtures/image.png"]) + message (or (some-> (get-in result [:error :message]) strip-ansi) "")] + (is (false? (:ok? result))) + (is (= :invalid-options (get-in result [:error :code]))) + (is (string/includes? message "--path is only valid in create mode")))) + + (testing "upsert asset rejects selector conflict" + (let [result (commands/parse-args ["upsert" "asset" + "--id" "42" + "--uuid" "11111111-1111-1111-1111-111111111111"])] + (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"])] @@ -2381,7 +2489,7 @@ (is (= :skill-install (get-in result [:action :type]))) (is (true? (get-in result [:action :global?])))))) -(deftest test-build-action-inspect-edit-add-upsert +(deftest ^:large-vars/cleanup-todo test-build-action-inspect-edit-add-upsert (testing "list page requires repo" (let [parsed {:ok? true :command :list-page :options {}} result (commands/build-action parsed {})] @@ -2403,6 +2511,14 @@ (is (= "project,work" (get-in result [:action :options :tags]))) (is (= "status" (get-in result [:action :options :properties]))))) + (testing "list asset builds action" + (let [parsed {:ok? true :command :list-asset :options {:limit 10 :sort "updated-at"}} + result (commands/build-action parsed {:graph "demo"})] + (is (true? (:ok? result))) + (is (= :list-asset (get-in result [:action :type]))) + (is (= "logseq_db_demo" (get-in result [:action :repo]))) + (is (= 10 (get-in result [:action :options :limit]))))) + (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"})] @@ -2463,7 +2579,27 @@ :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])))))) + (is (= :upsert-task (get-in result [:action :type]))))) + + (testing "upsert asset create builds action with default title" + (let [parsed {:ok? true + :command :upsert-asset + :options {:path "/tmp/asset-name.png"}} + result (commands/build-action parsed {:graph "demo"})] + (is (true? (:ok? result))) + (is (= :upsert-asset (get-in result [:action :type]))) + (is (= :create (get-in result [:action :mode]))) + (is (= "asset-name" (get-in result [:action :blocks 0 :block/title]))))) + + (testing "upsert asset update builds action" + (let [parsed {:ok? true + :command :upsert-asset + :options {:id 42 :content "New asset title"}} + result (commands/build-action parsed {:graph "demo"})] + (is (true? (:ok? result))) + (is (= :upsert-asset (get-in result [:action :type]))) + (is (= :update (get-in result [:action :mode]))) + (is (= 42 (get-in result [:action :id])))))) (deftest test-build-action-upsert-tag-property @@ -3596,6 +3732,30 @@ (is false (str "unexpected error: " e)))) (p/finally done))))) +(deftest test-execute-asset-dispatch + (async done + (let [calls* (atom [])] + (-> (p/with-redefs [cli-server/list-graphs (fn [_] ["demo"]) + list-command/execute-list-asset (fn [action _] + (swap! calls* conj action) + (p/resolved {:status :ok + :data {:items []}})) + upsert-command/execute-upsert-asset (fn [action _] + (swap! calls* conj action) + (p/resolved {:status :ok + :data {:result [202]}}))] + (p/let [list-result (commands/execute {:type :list-asset :repo "logseq_db_demo"} {}) + upsert-result (commands/execute {:type :upsert-asset :repo "logseq_db_demo"} {})] + (is (= :ok (:status list-result))) + (is (= :list-asset (:command list-result))) + (is (= :ok (:status upsert-result))) + (is (= :upsert-asset (:command upsert-result))) + (is (= [:list-asset :upsert-asset] + (mapv :type @calls*))))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally done))))) + (deftest test-execute-server-cleanup-dispatch (async done (-> (p/with-redefs [server-command/execute-cleanup (fn [action config] diff --git a/src/test/logseq/cli/format_test.cljs b/src/test/logseq/cli/format_test.cljs index 5279a3c69a..35f59509db 100644 --- a/src/test/logseq/cli/format_test.cljs +++ b/src/test/logseq/cli/format_test.cljs @@ -295,6 +295,23 @@ (is (string/includes? result "Node Block")) (is (string/includes? result "Count: 2")))) +(deftest test-human-output-list-asset + (let [result (format/format-result {:status :ok + :command :list-asset + :data {:items [{:db/id 3 + :block/title "Asset Node" + :node/type "block" + :block/page-id 1 + :block/page-title "Assets" + :block/created-at 40000 + :block/updated-at 90000}]}} + {:output-format nil + :now-ms 100000})] + (is (string/includes? result "TYPE")) + (is (string/includes? result "PAGE-ID")) + (is (string/includes? result "Asset Node")) + (is (string/includes? result "Count: 1")))) + (deftest test-human-output-list-title-max-display-width (doseq [[label command item] [["list page truncates title by configured display width" @@ -329,6 +346,15 @@ :block/title "ABCDEFGH" :node/type "page" :block/updated-at 90000 + :block/created-at 40000}] + ["list asset truncates title by configured display width" + :list-asset + {:db/id 6 + :block/title "ABCDEFGH" + :node/type "block" + :block/page-id 1 + :block/page-title "Assets" + :block/updated-at 90000 :block/created-at 40000}]]] (testing label (let [result (format/format-result {:status :ok @@ -474,7 +500,13 @@ :context {:repo "demo-repo" :page "Weekly Plan"} :data {:result [987]}} - "Upserted task:\n[987]"]]] + "Upserted task:\n[987]"] + ["upsert asset renders ids in two lines" + {:status :ok + :command :upsert-asset + :context {:repo "demo-repo"} + :data {:result [555]}} + "Upserted asset:\n[555]"]]] (testing label (let [result (format/format-result payload {:output-format nil})] (is (= expected result))))))