diff --git a/docs/agent-guide/043-logseq-cli-tag-property-management.md b/docs/agent-guide/043-logseq-cli-tag-property-management.md new file mode 100644 index 0000000000..906a892bcb --- /dev/null +++ b/docs/agent-guide/043-logseq-cli-tag-property-management.md @@ -0,0 +1,182 @@ +# Logseq CLI Tag and Property Management Implementation Plan + +Goal: Add first class CLI support for `upsert tag`, `upsert property`, `remove tag`, and `remove property`, and restructure existing remove behavior into `remove block` and `remove page`. + +Architecture: Replace current `add tag` command entry with `upsert tag` so tag creation and idempotent update semantics are unified under one verb. +Architecture: Expand `remove` into typed subcommands (`block`, `page`, `tag`, `property`) to make deletion intent explicit and prevent mixed selector ambiguity. +Architecture: Reuse `:thread-api/apply-outliner-ops` in db-worker-node so no new HTTP route or transport protocol is introduced. + +Tech Stack: ClojureScript, babashka.cli, Promesa, Datascript, db-worker-node, outliner ops. + +Related: Builds on docs/agent-guide/042-logseq-cli-add-tag-command.md and docs/agent-guide/029-logseq-cli-show-properties.md. + +## Problem statement + +Current CLI supports tag creation through `add tag` in `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/add.cljs`, but does not expose an `upsert` verb for tags and properties. + +Current `update` in `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/update.cljs` updates tag and property values on blocks, but does not upsert or remove tag/property entities. + +Current `remove` in `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/remove.cljs` mixes block and page deletion under one command, and has no explicit tag/property deletion path. + +db-worker-node already supports required mutation primitives through `:thread-api/apply-outliner-ops` in `/Users/rcmerci/gh-repos/logseq/src/main/frontend/worker/db_core.cljs` and outliner ops in `/Users/rcmerci/gh-repos/logseq/deps/outliner/src/logseq/outliner/op.cljs`. + +The implementation gap is CLI command surface design, parser wiring, validation, and output contract coverage. + +## Testing Plan + +I will follow `@test-driven-development` and write failing parser, action, executor, formatter, and integration tests before adding behavior. + +I will add parser tests for the new `upsert` verb and the new typed `remove` subcommands. + +I will add action builder tests that verify repo propagation, normalized names, schema coercion, and typed action payloads for each new command. + +I will add executor tests that stub transport and assert exact outliner ops emitted for each command path. + +I will add formatter tests for human and json output so command responses are stable for scripts. + +I will add integration tests against db-worker-node that verify graph state changes through `list`, `show`, and entity queries. + +I will use `@clojure-debug` only when failures are caused by test setup rather than behavior. + +NOTE: I will write *all* tests before I add any implementation behavior. + +## Command contract + +The final command surface is top level verbs without a `manage` group. + +| Command | Required options | Optional options | Behavior | +| --- | --- | --- | --- | +| `logseq upsert tag` | `--name` | none in v1 | Creates tag when missing, returns existing tag when already present, and errors if same title exists as non-tag page. | +| `logseq upsert property` | `--name` | `--type`, `--cardinality`, `--hide`, `--public` | Creates property when missing, updates schema for existing property, and validates type or cardinality compatibility. | +| `logseq remove tag` | one of `--name`, `--id` | none | Deletes a tag entity after validating target type and removability, and fails with a candidate list when `--name` matches multiple tags. | +| `logseq remove property` | one of `--name`, `--id` | none | Deletes a property entity after validating target type and removability, and fails with a candidate list when `--name` matches multiple properties. | +| `logseq remove block` | one of `--id`, `--uuid` | none | Deletes one or more blocks with existing block remove behavior. | +| `logseq remove page` | `--name` | none | Deletes a page with existing page remove behavior. | + +`add tag` will be removed from `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/add.cljs` and replaced by `upsert tag`. + +Bare `remove` without a subcommand will be rejected with a clear parse error. + +No compatibility aliases or deprecation shims will be kept for removed commands. + +## Integration overview + +```text +logseq upsert property --name "owner" --type node --cardinality many + -> /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/transport.cljs (/v1/invoke) + -> /Users/rcmerci/gh-repos/logseq/src/main/frontend/worker/db_core.cljs (:thread-api/apply-outliner-ops) + -> /Users/rcmerci/gh-repos/logseq/deps/outliner/src/logseq/outliner/op.cljs (:upsert-property) +``` + +```text +logseq remove tag --name "Quote" + -> /Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/remove.cljs + -> remove tag resolver validates :logseq.class/Tag + -> :thread-api/apply-outliner-ops with [:delete-page [tag-uuid]] + -> outliner page delete flow applies class cleanup and persistence +``` + +## Detailed implementation plan + +1. Add failing parse help tests in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs` that expect top level `upsert` and `remove` subcommands (`block`, `page`, `tag`, `property`). +2. Add failing parse tests for `['upsert' 'tag' '--name' 'Quote']` and `['upsert' 'property' '--name' 'owner' '--type' 'node' '--cardinality' 'many']` in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs`. +3. Add failing parse tests for `['remove' 'tag' '--name' 'Quote']` and `['remove' 'property' '--id' '123']` in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs`. +4. Add failing parse tests for `['remove' 'block' '--id' '1']` and `['remove' 'page' '--name' 'Home']` to preserve old delete behavior in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs`. +5. Add failing parse validation tests that reject bare `remove` and old `add tag` command usage in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs`. +6. Add failing parse validation tests for invalid property type and cardinality values in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs`. +7. Add failing build action tests for `upsert tag` name normalization and `#` prefix stripping in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs`. +8. Add failing build action tests for `upsert property` schema coercion to `:logseq.property/type` and `:db/cardinality` in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs`. +9. Add failing executor tests that `upsert tag` emits `[:create-page [name {:class? true}]]` only when the tag is missing in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs`. +10. Add failing executor tests that `upsert tag` is idempotent for existing tag entities and rejects non-tag title conflicts in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs`. +11. Add failing executor tests that `upsert property` emits `[:upsert-property [property-id schema opts]]` and passes `{:property-name name}` when creating a new property in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs`. +12. Add failing executor tests that `remove tag` and `remove property` resolve entities by `--name` or `--id` and emit `[:delete-page [uuid]]` in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs`. +13. Add failing executor tests that `remove tag --name` and `remove property --name` fail on multiple matches, return all matched candidates in output, and require rerun with `--id` in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs`. +14. Add failing executor tests that built in or hidden tag or property targets are rejected with explicit error codes in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs`. +15. Add failing formatter tests for `upsert tag`, `upsert property`, `remove tag`, and `remove property` outputs in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/format_test.cljs`. +16. Add failing formatter tests that `remove block` and `remove page` outputs remain backward compatible in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/format_test.cljs`. +17. Add failing integration tests for `upsert tag` create and idempotent behavior in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/integration_test.cljs`. +18. Add failing integration tests for `upsert property` create and schema update behavior in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/integration_test.cljs`. +19. Add failing integration tests for `remove tag` and `remove property` ensuring entities disappear from `list tag` and `list property` in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/integration_test.cljs`. +20. Add failing integration tests for `remove tag --name` and `remove property --name` ambiguous matches to assert candidate list output and `--id` guidance in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/integration_test.cljs`. +21. Add failing integration tests for `remove block` and `remove page` command migration behavior in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/integration_test.cljs`. +22. Run focused tests and confirm all new tests fail for behavior reasons before implementation. +23. Create `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/upsert.cljs` with command specs, entries, validation helpers, action builders, and executors for tag and property upsert. +24. Refactor shared tag resolver helpers from `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/add.cljs` into `upsert.cljs` or a shared helper namespace to avoid duplication. +25. Remove `add tag` command entry and related build or execute dispatch from `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/add.cljs` and `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/commands.cljs`. +26. Extend `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/remove.cljs` to register `remove block`, `remove page`, `remove tag`, and `remove property` entries. +27. Keep existing block and page delete execution logic and map it behind `remove block` and `remove page` subcommands. +28. Implement new remove tag and remove property resolver and execution paths in `remove.cljs` using `:delete-page` outliner op after entity type validation and ambiguity failure behavior for `--name`. +29. Update `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/commands.cljs` table, parse validation, action builder, context propagation, and execute dispatch for new upsert and remove contracts. +30. Update `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/format.cljs` to format results for `upsert tag`, `upsert property`, `remove tag`, and `remove property`, including ambiguous candidate lists. +31. Run focused unit and integration tests, then run `bb dev:lint-and-test`, and keep only behavior preserving refactors. +32. Update CLI help text snapshots and any docs references to remove `add tag` and bare `remove` usage. + +## Edge cases to cover + +Tag names with leading `#` should normalize consistently in `upsert tag`. + +Tag and property names that collide by title or case must fail resolution for `--name`, list all candidate matches, and require explicit `--id`. + +Remove selectors must reject ambiguous name matches with a clear error, include all candidate ids and names in output, and require `--id`. + +Built in tags and built in properties must not be removable. + +Property type and cardinality updates must reject invalid transitions already enforced by outliner validation. + +`remove block` multi id behavior must stay unchanged from current implementation. + +`remove page` must preserve current not found and built in page deletion behavior. + +Commands must reject blank names after trim. + +## Verification commands + +| Command | Expected result | +| --- | --- | +| `bb dev:test -v logseq.cli.commands-test/test-parse-args-help` | Help output includes `upsert` and typed `remove` subcommands. | +| `bb dev:test -v logseq.cli.commands-test/test-verb-subcommand-parse-upsert-remove` | Parse and validation tests for new command contracts pass. | +| `bb dev:test -v logseq.cli.commands-test/test-build-action-upsert-remove` | Action payload tests pass for all new paths. | +| `bb dev:test -v logseq.cli.commands-test/test-execute-upsert-remove-tag-property` | Outliner op emission tests pass for upsert and entity removal flows. | +| `bb dev:test -v logseq.cli.format-test/test-human-output-upsert-remove` | Human output formatting for new commands is stable. | +| `bb dev:test -v logseq.cli.integration-test/test-cli-upsert-and-remove-tag-property` | End to end behavior through db-worker-node passes for all four new commands. | +| `bb dev:test -v logseq.cli.integration-test/test-cli-remove-block-page-subcommands` | Block and page deletion still work through new remove subcommands. | +| `bb dev:lint-and-test` | Full lint and unit suite pass. | + +## Migration and compatibility + +No db-worker-node route migration is required because this feature reuses `/v1/invoke` and existing thread-api methods. + +No schema migration is required because tag and property entities are created and deleted through existing outliner operations. + +This is a CLI breaking change because `add tag` is removed and bare `remove` is replaced by typed `remove block` and `remove page` commands. + +This breaking change will be applied directly with no compatibility alias period. + +## Testing Details + +Tests validate parser, action, execution, formatter, and integration behavior for `upsert tag`, `upsert property`, `remove tag`, and `remove property`. + +Tests also validate migration behavior for `remove block` and `remove page` so previous block and page deletion semantics are preserved. + +Tests focus on command contracts and graph outcomes instead of helper internals. + +## Implementation Details + +- Add new upsert command module at `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/upsert.cljs`. +- Register upsert entries and dispatch hooks in `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/commands.cljs`. +- Remove `add tag` command entry and related dispatch from `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/add.cljs` and `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/commands.cljs`. +- Refactor remove command entries in `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/remove.cljs` to `remove block`, `remove page`, `remove tag`, and `remove property`. +- Reuse `:thread-api/apply-outliner-ops` with `:create-page`, `:upsert-property`, and `:delete-page` for all entity mutations. +- Add formatter branches in `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/format.cljs` for new commands. +- Add parser and executor unit tests in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs`. +- Add formatter tests in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/format_test.cljs`. +- Add end to end coverage in `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/integration_test.cljs`. +- Use `@clojure-debug` only when failures indicate fixture or async wiring issues. + +## Question + +No open questions. + +--- diff --git a/src/main/logseq/cli/command/add.cljs b/src/main/logseq/cli/command/add.cljs index b8017988ab..90c858923b 100644 --- a/src/main/logseq/cli/command/add.cljs +++ b/src/main/logseq/cli/command/add.cljs @@ -34,13 +34,9 @@ :tags {:desc "EDN vector of tags. Identifiers can be id, :db/ident, or :block/title."} :properties {:desc "EDN map of built-in properties. Identifiers can be id, :db/ident, or :block/title."}}) -(def ^:private add-tag-spec - {:name {:desc "Tag name"}}) - (def entries [(core/command-entry ["add" "block"] :add-block "Add blocks" content-add-spec) - (core/command-entry ["add" "page"] :add-page "Create page" add-page-spec) - (core/command-entry ["add" "tag"] :add-tag "Create tag" add-tag-spec)]) + (core/command-entry ["add" "page"] :add-page "Create page" add-page-spec)]) (defn- today-page-title [config repo] @@ -1011,43 +1007,6 @@ :error {:code :missing-page-name :message "page name is required"}})))) -(defn- normalize-tag-name-option - [value] - (let [normalized (normalize-tag-value value)] - (when (string? normalized) - (let [name (string/trim normalized)] - (when (seq name) - name))))) - -(defn build-add-tag-action - [options repo] - (if-not (seq repo) - {:ok? false - :error {:code :missing-repo - :message "repo is required for add"}} - (let [name (normalize-tag-name-option (:name options))] - (if (seq name) - {:ok? true - :action {:type :add-tag - :repo repo - :graph (core/repo->graph repo) - :name name}} - {:ok? false - :error {:code :missing-tag-name - :message "tag name is required"}})))) - -(defn- pull-page-by-name - [config repo page-name] - (pull-entity config repo - [:db/id :block/name :block/title :block/uuid - {:block/tags [:db/id :db/ident :block/name :block/title]}] - [:block/name (common-util/page-name-sanity-lc page-name)])) - -(defn- tag-entity? - [entity] - (some #(= :logseq.class/Tag (:db/ident %)) - (:block/tags entity))) - (defn execute-add-block [action config] (-> (p/let [cfg (cli-server/ensure-server! config (:repo action)) @@ -1134,33 +1093,3 @@ created-ids (resolve-created-page-ids cfg (:repo action) (:page action) create-result)] {:status :ok :data {:result created-ids}}))) - -(defn execute-add-tag - [action config] - (-> (p/let [cfg (cli-server/ensure-server! config (:repo action)) - existing (pull-page-by-name cfg (:repo action) (:name action)) - existing-id (:db/id existing) - _ (when (and existing-id (not (tag-entity? existing))) - (throw (ex-info "tag already exists as a page and is not a tag" - {:code :tag-name-conflict - :name (:name action)}))) - _ (when-not existing-id - (transport/invoke cfg :thread-api/apply-outliner-ops false - [(:repo action) - [[:create-page [(:name action) {:class? true}]]] - {}])) - page (or (when existing-id existing) - (pull-page-by-name cfg (:repo action) (:name action))) - page-id (:db/id page) - _ (when-not page-id - (throw (ex-info "tag not found after create" - {:code :tag-not-found - :name (:name action)}))) - _ (when-not (tag-entity? page) - (throw (ex-info "created entity is not tagged as :logseq.class/Tag" - {:code :tag-create-not-tag - :name (:name action) - :id page-id}))) - created-ids (normalize-created-ids [page-id])] - {:status :ok - :data {:result created-ids}}))) diff --git a/src/main/logseq/cli/command/core.cljs b/src/main/logseq/cli/command/core.cljs index 351b7e06f6..cb9ac273d3 100644 --- a/src/main/logseq/cli/command/core.cljs +++ b/src/main/logseq/cli/command/core.cljs @@ -93,7 +93,7 @@ (defn top-level-summary [table] (let [groups [{:title "Graph Inspect and Edit" - :commands #{"list" "add" "remove" "update" "query" "show"}} + :commands #{"list" "add" "upsert" "remove" "update" "query" "show"}} {:title "Graph Management" :commands #{"graph" "server" "doctor"}}] render-group (fn [{:keys [title commands]}] diff --git a/src/main/logseq/cli/command/remove.cljs b/src/main/logseq/cli/command/remove.cljs index 3bbc3d82c3..79ec672627 100644 --- a/src/main/logseq/cli/command/remove.cljs +++ b/src/main/logseq/cli/command/remove.cljs @@ -8,27 +8,66 @@ [logseq.common.util :as common-util] [promesa.core :as p])) -(def ^:private remove-spec +(def ^:private remove-block-spec {:id {:desc "Block db/id or EDN vector of ids"} - :uuid {:desc "Block UUID"} - :page {:desc "Page name"}}) + :uuid {:desc "Block UUID"}}) + +(def ^:private remove-page-spec + {:name {:desc "Page name"}}) + +(def ^:private remove-entity-spec + {:id {:desc "Entity db/id" + :coerce :long} + :name {:desc "Entity name"}}) (def entries - [(core/command-entry ["remove"] :remove "Remove blocks or pages" remove-spec)]) + [(core/command-entry ["remove" "block"] :remove-block "Remove blocks" remove-block-spec) + (core/command-entry ["remove" "page"] :remove-page "Remove page" remove-page-spec) + (core/command-entry ["remove" "tag"] :remove-tag "Remove tag" remove-entity-spec) + (core/command-entry ["remove" "property"] :remove-property "Remove property" remove-entity-spec)]) (defn invalid-options? - [opts] - (let [id-result (id-command/parse-id-option (:id opts))] - (cond - (and (some? (:id opts)) (not (:ok? id-result))) - (:message id-result) + [command opts] + (case command + :remove-block + (let [id-result (id-command/parse-id-option (:id opts)) + selectors (filter some? [(:id opts) (some-> (:uuid opts) string/trim)])] + (cond + (and (some? (:id opts)) (not (:ok? id-result))) + (:message id-result) - :else - nil))) + (> (count selectors) 1) + "only one of --id or --uuid is allowed" + + :else + nil)) + + (:remove-tag :remove-property) + (let [name (some-> (:name opts) string/trim) + selectors (filter some? [(:id opts) name])] + (cond + (> (count selectors) 1) + "only one of --id or --name is allowed" + + (and (contains? opts :name) (string/blank? (or (:name opts) ""))) + "name must be non-empty" + + :else + nil)) + + nil)) (def ^:private block-id-selector [:db/id :block/uuid]) +(def ^:private page-id-selector + [:db/id :block/uuid :block/name :block/title]) + +(def ^:private entity-selector + [:db/id :db/ident :block/uuid :block/name :block/title + :logseq.property/type :logseq.property/public? :logseq.property/built-in? + {:block/tags [:db/id :db/ident :block/title :block/name]}]) + (defn- fetch-block-by-id [config repo id] (transport/invoke config :thread-api/pull false @@ -48,6 +87,11 @@ (transport/invoke config :thread-api/apply-outliner-ops false [repo [[:delete-blocks [ids {}]]] {}])) +(defn- delete-page-by-uuid + [config repo page-uuid] + (transport/invoke config :thread-api/apply-outliner-ops false + [repo [[:delete-page [page-uuid]]] {}])) + (defn- remove-block-id [config repo id] (p/let [entity (fetch-block-by-id config repo id)] @@ -74,8 +118,8 @@ :missing-ids missing-ids :result result})) -(defn- perform-remove - [config {:keys [repo ids multi-id? uuid page]}] +(defn- perform-remove-block + [config {:keys [repo ids multi-id? uuid]}] (cond (and (seq ids) multi-id?) (remove-block-ids-best-effort config repo ids) @@ -91,59 +135,288 @@ (delete-block-ids config repo [id]) (throw (ex-info "block not found" {:code :block-not-found}))))) - (seq page) - (p/let [entity (transport/invoke config :thread-api/pull false - [repo [:db/id :block/uuid] [:block/name page]])] - (if-let [page-uuid (:block/uuid entity)] - (transport/invoke config :thread-api/apply-outliner-ops false - [repo [[:delete-page [page-uuid]]] {}]) - (throw (ex-info "page not found" {:code :page-not-found})))) + :else + (p/rejected (ex-info "block is required" {:code :missing-target})))) + +(defn- resolve-page-by-name + [config repo name] + (transport/invoke config :thread-api/pull false + [repo page-id-selector [:block/name (common-util/page-name-sanity-lc name)]])) + +(defn- item-id + [item] + (or (:db/id item) (:id item))) + +(defn- item-name + [item] + (or (:block/title item) (:title item) (:name item) (:block/name item))) + +(defn- normalize-name + [value] + (common-util/page-name-sanity-lc (or value ""))) + +(defn- tag-entity? + [entity] + (some #(= :logseq.class/Tag (:db/ident %)) + (:block/tags entity))) + +(defn- property-entity? + [entity] + (some? (:logseq.property/type entity))) + +(defn- list-matches-by-name + [config repo method name] + (let [normalized (normalize-name name)] + (p/let [items (transport/invoke config method false [repo {:include-built-in true :expand true}]) + matches (->> (or items []) + (filter (fn [item] + (= normalized (normalize-name (item-name item))))) + vec)] + matches))) + +(defn- ambiguous-error + [code label name matches] + (let [candidates (->> matches + (map (fn [item] + {:id (item-id item) + :name (item-name item)})) + (filter :id) + vec)] + {:code code + :message (str "multiple " label "s match name: " name "; rerun with --id") + :candidates candidates})) + +(defn- resolve-target + [config repo {:keys [id name]} {:keys [list-method not-found-code ambiguous-code label]}] + (cond + (some? id) + (p/resolved {:ok? true + :lookup id + :id id}) + + (seq name) + (p/let [matches (list-matches-by-name config repo list-method name)] + (cond + (empty? matches) + {:ok? false + :error {:code not-found-code + :message (str label " not found")}} + + (> (count matches) 1) + {:ok? false + :error (ambiguous-error ambiguous-code label name matches)} + + :else + {:ok? true + :lookup [:block/name (normalize-name (or (item-name (first matches)) name))] + :id (item-id (first matches)) + :name (item-name (first matches))})) :else - (p/rejected (ex-info "block or page required" {:code :missing-target})))) + (p/resolved {:ok? false + :error {:code :missing-target + :message (str label " name or id is required")}}))) + +(defn- validate-tag-target + [entity] + (cond + (nil? (:db/id entity)) + {:ok? false + :error {:code :tag-not-found + :message "tag not found"}} + + (not (tag-entity? entity)) + {:ok? false + :error {:code :invalid-tag-target + :message "target is not a tag"}} + + (true? (:logseq.property/built-in? entity)) + {:ok? false + :error {:code :tag-built-in + :message "built-in tag cannot be removed"}} + + (false? (:logseq.property/public? entity)) + {:ok? false + :error {:code :tag-hidden + :message "hidden tag cannot be removed"}} + + (nil? (:block/uuid entity)) + {:ok? false + :error {:code :tag-not-found + :message "tag uuid not found"}} + + :else + {:ok? true + :entity entity})) + +(defn- validate-property-target + [entity] + (cond + (nil? (:db/id entity)) + {:ok? false + :error {:code :property-not-found + :message "property not found"}} + + (not (property-entity? entity)) + {:ok? false + :error {:code :invalid-property-target + :message "target is not a property"}} + + (true? (:logseq.property/built-in? entity)) + {:ok? false + :error {:code :property-built-in + :message "built-in property cannot be removed"}} + + (false? (:logseq.property/public? entity)) + {:ok? false + :error {:code :property-hidden + :message "hidden property cannot be removed"}} + + (nil? (:block/uuid entity)) + {:ok? false + :error {:code :property-not-found + :message "property uuid not found"}} + + :else + {:ok? true + :entity entity})) + +(defn- perform-remove-entity + [config action {:keys [list-method not-found-code ambiguous-code label validate-fn]}] + (p/let [resolved (resolve-target config (:repo action) action + {:list-method list-method + :not-found-code not-found-code + :ambiguous-code ambiguous-code + :label label})] + (if-not (:ok? resolved) + {:status :error + :error (:error resolved)} + (p/let [entity (transport/invoke config :thread-api/pull false + [(:repo action) entity-selector (:lookup resolved)]) + validation (validate-fn entity)] + (if-not (:ok? validation) + {:status :error + :error (:error validation)} + (p/let [result (delete-page-by-uuid config (:repo action) (:block/uuid entity))] + {:status :ok + :data {:result result + :id (:db/id entity) + :name (or (:name resolved) (:block/title entity) (:block/name entity))}})))))) (defn build-action - [options repo] + [command options repo] (if-not (seq repo) {:ok? false :error {:code :missing-repo :message "repo is required for remove"}} - (let [id-result (id-command/parse-id-option (:id options)) - ids (:value id-result) - multi-id? (:multi? id-result) - uuid (some-> (:uuid options) string/trim) - page (some-> (:page options) string/trim) - selectors (filter some? [(:id options) uuid page])] - (cond - (empty? selectors) - {:ok? false - :error {:code :missing-target - :message "block or page is required"}} + (case command + :remove-block + (let [id-result (id-command/parse-id-option (:id options)) + ids (:value id-result) + multi-id? (:multi? id-result) + uuid (some-> (:uuid options) string/trim) + selectors (filter some? [(:id options) uuid])] + (cond + (empty? selectors) + {:ok? false + :error {:code :missing-target + :message "block is required"}} - (> (count selectors) 1) - {:ok? false - :error {:code :invalid-options - :message "only one of --id, --uuid, or --page is allowed"}} + (> (count selectors) 1) + {:ok? false + :error {:code :invalid-options + :message "only one of --id or --uuid is allowed"}} - (and (some? (:id options)) (not (:ok? id-result))) - {:ok? false - :error {:code :invalid-options - :message (:message id-result)}} + (and (some? (:id options)) (not (:ok? id-result))) + {:ok? false + :error {:code :invalid-options + :message (:message id-result)}} - :else - {:ok? true - :action {:type :remove - :repo repo - :id (when (and (seq ids) (not multi-id?)) (first ids)) - :ids ids - :multi-id? multi-id? - :uuid uuid - :page page}})))) + :else + {:ok? true + :action {:type :remove-block + :repo repo + :graph (core/repo->graph repo) + :id (when (and (seq ids) (not multi-id?)) (first ids)) + :ids ids + :multi-id? multi-id? + :uuid uuid}})) -(defn execute-remove + :remove-page + (let [name (some-> (:name options) string/trim)] + (if (seq name) + {:ok? true + :action {:type :remove-page + :repo repo + :graph (core/repo->graph repo) + :name name}} + {:ok? false + :error {:code :missing-page-name + :message "page name is required"}})) + + (:remove-tag :remove-property) + (let [name (some-> (:name options) string/trim) + id (:id options) + selectors (filter some? [id name])] + (cond + (empty? selectors) + {:ok? false + :error {:code :missing-target + :message "name or id is required"}} + + (> (count selectors) 1) + {:ok? false + :error {:code :invalid-options + :message "only one of --id or --name is allowed"}} + + :else + {:ok? true + :action {:type command + :repo repo + :graph (core/repo->graph repo) + :id id + :name name}})) + + {:ok? false + :error {:code :unknown-command + :message (str "unknown remove command: " command)}}))) + +(defn execute-remove-block [action config] (-> (p/let [cfg (cli-server/ensure-server! config (:repo action)) - result (perform-remove cfg action)] + result (perform-remove-block cfg action)] {:status :ok :data (cond-> {:result result} (map? result) (merge (dissoc result :result)))}))) + +(defn execute-remove-page + [action config] + (-> (p/let [cfg (cli-server/ensure-server! config (:repo action)) + entity (resolve-page-by-name cfg (:repo action) (:name action))] + (if-let [page-uuid (:block/uuid entity)] + (p/let [result (delete-page-by-uuid cfg (:repo action) page-uuid)] + {:status :ok + :data {:result result}}) + {:status :error + :error {:code :page-not-found + :message "page not found"}})))) + +(defn execute-remove-tag + [action config] + (-> (p/let [cfg (cli-server/ensure-server! config (:repo action))] + (perform-remove-entity cfg action + {:list-method :thread-api/api-list-tags + :not-found-code :tag-not-found + :ambiguous-code :ambiguous-tag-name + :label "tag" + :validate-fn validate-tag-target})))) + +(defn execute-remove-property + [action config] + (-> (p/let [cfg (cli-server/ensure-server! config (:repo action))] + (perform-remove-entity cfg action + {:list-method :thread-api/api-list-properties + :not-found-code :property-not-found + :ambiguous-code :ambiguous-property-name + :label "property" + :validate-fn validate-property-target})))) diff --git a/src/main/logseq/cli/command/upsert.cljs b/src/main/logseq/cli/command/upsert.cljs new file mode 100644 index 0000000000..94b91d5a6b --- /dev/null +++ b/src/main/logseq/cli/command/upsert.cljs @@ -0,0 +1,227 @@ +(ns logseq.cli.command.upsert + "Upsert-related CLI commands." + (:require [clojure.string :as string] + [logseq.cli.command.core :as core] + [logseq.cli.server :as cli-server] + [logseq.cli.transport :as transport] + [logseq.common.util :as common-util] + [promesa.core :as p])) + +(def ^:private upsert-tag-spec + {:name {:desc "Tag name"}}) + +(def ^:private upsert-property-spec + {:name {:desc "Property name"} + :type {:desc "Property type (default, number, date, datetime, checkbox, url, node, json, string)"} + :cardinality {:desc "Property cardinality (one, many)"} + :hide {:desc "Hide property" + :coerce :boolean} + :public {:desc "Set property public visibility" + :coerce :boolean}}) + +(def entries + [(core/command-entry ["upsert" "tag"] :upsert-tag "Upsert tag" upsert-tag-spec) + (core/command-entry ["upsert" "property"] :upsert-property "Upsert property" upsert-property-spec)]) + +(def ^:private property-types + #{"default" "number" "date" "datetime" "checkbox" "url" "node" "json" "string"}) + +(def ^:private property-cardinalities + #{"one" "many"}) + +(defn- normalize-tag-name + [value] + (let [text (some-> value string/trim (string/replace #"^#+" ""))] + (when (seq text) + text))) + +(defn- normalize-property-name + [value] + (let [text (some-> value string/trim)] + (when (seq text) + text))) + +(defn- normalize-property-type + [value] + (some-> value string/trim string/lower-case)) + +(defn- normalize-property-cardinality + [value] + (let [v (some-> value string/trim string/lower-case)] + (case v + "db.cardinality/one" "one" + "db.cardinality/many" "many" + v))) + +(defn invalid-options? + [command opts] + (case command + :upsert-property + (let [type' (normalize-property-type (:type opts)) + cardinality' (normalize-property-cardinality (:cardinality opts))] + (cond + (and (seq (:type opts)) (not (contains? property-types type'))) + (str "invalid type: " (:type opts)) + + (and (seq (:cardinality opts)) (not (contains? property-cardinalities cardinality'))) + (str "invalid cardinality: " (:cardinality opts)) + + :else + nil)) + + nil)) + +(defn build-tag-action + [options repo] + (if-not (seq repo) + {:ok? false + :error {:code :missing-repo + :message "repo is required for upsert"}} + (let [name (normalize-tag-name (:name options))] + (if (seq name) + {:ok? true + :action {:type :upsert-tag + :repo repo + :graph (core/repo->graph repo) + :name name}} + {:ok? false + :error {:code :missing-tag-name + :message "tag name is required"}})))) + +(defn- cardinality->db + [value] + (when-let [v (normalize-property-cardinality value)] + (case v + "many" :db.cardinality/many + "one" :db.cardinality/one + nil))) + +(defn- property-schema + [options] + (cond-> {} + (seq (:type options)) + (assoc :logseq.property/type (keyword (normalize-property-type (:type options)))) + + (seq (:cardinality options)) + (assoc :db/cardinality (cardinality->db (:cardinality options))) + + (contains? options :hide) + (assoc :logseq.property/hide? (boolean (:hide options))) + + (contains? options :public) + (assoc :logseq.property/public? (boolean (:public options))))) + +(defn build-property-action + [options repo] + (if-not (seq repo) + {:ok? false + :error {:code :missing-repo + :message "repo is required for upsert"}} + (let [name (normalize-property-name (:name options)) + invalid-message (invalid-options? :upsert-property options)] + (cond + (not (seq name)) + {:ok? false + :error {:code :missing-property-name + :message "property name is required"}} + + (seq invalid-message) + {:ok? false + :error {:code :invalid-options + :message invalid-message}} + + :else + {:ok? true + :action {:type :upsert-property + :repo repo + :graph (core/repo->graph repo) + :name name + :schema (property-schema options)}})))) + +(defn- pull-page-by-name + [config repo page-name selector] + (transport/invoke config :thread-api/pull false + [repo selector [:block/name (common-util/page-name-sanity-lc page-name)]])) + +(defn- tag-entity? + [entity] + (some #(= :logseq.class/Tag (:db/ident %)) + (:block/tags entity))) + +(defn execute-upsert-tag + [action config] + (-> (p/let [cfg (cli-server/ensure-server! config (:repo action)) + existing (pull-page-by-name cfg (:repo action) (:name action) + [:db/id :block/name :block/title + {:block/tags [:db/ident]}]) + existing-id (:db/id existing)] + (cond + (and existing-id (not (tag-entity? existing))) + {:status :error + :error {:code :tag-name-conflict + :message "tag already exists as a page and is not a tag"}} + + :else + (p/let [_ (when-not existing-id + (transport/invoke cfg :thread-api/apply-outliner-ops false + [(:repo action) + [[:create-page [(:name action) {:class? true}]]] + {}])) + page (or (when existing-id existing) + (pull-page-by-name cfg (:repo action) (:name action) + [:db/id :block/name :block/title + {:block/tags [:db/ident]}])) + page-id (:db/id page)] + (cond + (not page-id) + {:status :error + :error {:code :tag-not-found + :message "tag not found after upsert"}} + + (not (tag-entity? page)) + {:status :error + :error {:code :tag-create-not-tag + :message "created entity is not tagged as :logseq.class/Tag"}} + + :else + {:status :ok + :data {:result [page-id]}})))))) + +(def ^:private property-selector + [:db/id :db/ident :block/name :block/title :logseq.property/type]) + +(defn- property-entity? + [entity] + (some? (:logseq.property/type entity))) + +(defn execute-upsert-property + [action config] + (-> (p/let [cfg (cli-server/ensure-server! config (:repo action)) + existing (pull-page-by-name cfg (:repo action) (:name action) property-selector) + existing-id (:db/id existing)] + (cond + (and existing-id (not (property-entity? existing))) + {:status :error + :error {:code :property-name-conflict + :message "property already exists as a page and is not a property"}} + + :else + (p/let [property-ident (when (property-entity? existing) + (:db/ident existing)) + property-opts (cond-> {} + (nil? property-ident) + (assoc :property-name (:name action))) + _ (transport/invoke cfg :thread-api/apply-outliner-ops false + [(:repo action) + [[:upsert-property [property-ident + (:schema action) + property-opts]]] + {}]) + property (pull-page-by-name cfg (:repo action) (:name action) property-selector) + property-id (:db/id property)] + (if property-id + {:status :ok + :data {:result [property-id]}} + {:status :error + :error {:code :property-not-found + :message "property not found after upsert"}})))))) diff --git a/src/main/logseq/cli/commands.cljs b/src/main/logseq/cli/commands.cljs index b5668c728f..2a3bde01a1 100644 --- a/src/main/logseq/cli/commands.cljs +++ b/src/main/logseq/cli/commands.cljs @@ -11,6 +11,7 @@ [logseq.cli.command.remove :as remove-command] [logseq.cli.command.server :as server-command] [logseq.cli.command.show :as show-command] + [logseq.cli.command.upsert :as upsert-command] [logseq.cli.command.update :as update-command] [logseq.cli.server :as cli-server] [promesa.core :as p])) @@ -69,6 +70,13 @@ :message "tag name is required"} :summary summary}) +(defn- missing-property-name-result + [summary] + {:ok? false + :error {:code :missing-property-name + :message "property name is required"} + :summary summary}) + (defn- missing-type-result [summary] {:ok? false @@ -106,6 +114,7 @@ server-command/entries list-command/entries add-command/entries + upsert-command/entries remove-command/entries update-command/entries query-command/entries @@ -153,9 +162,6 @@ (seq (:blocks-file opts)) has-args?) show-targets (filter some? [(:id opts) (:uuid opts) (:page opts)]) - remove-targets (filter some? [(:id opts) - (some-> (:uuid opts) string/trim) - (some-> (:page opts) string/trim)]) update-sources (filter some? [(:id opts) (some-> (:uuid opts) string/trim)])] (cond (:help opts) @@ -174,17 +180,24 @@ (and (= command :add-page) (not (seq (:page opts)))) (missing-page-name-result summary) - (and (= command :add-tag) (not (seq (some-> (:name opts) string/trim)))) + (and (= command :upsert-tag) (not (seq (some-> (:name opts) string/trim)))) (missing-tag-name-result summary) - (and (= command :remove) (seq args)) - (command-core/invalid-options-result summary "remove does not accept subcommands") + (and (= command :upsert-property) (not (seq (some-> (:name opts) string/trim)))) + (missing-property-name-result summary) - (and (= command :remove) (empty? remove-targets)) + (and (= command :upsert-property) (upsert-command/invalid-options? command opts)) + (command-core/invalid-options-result summary (upsert-command/invalid-options? command opts)) + + (and (= command :remove-block) (empty? (filter some? [(:id opts) (some-> (:uuid opts) string/trim)]))) (missing-target-result summary) - (and (= command :remove) (> (count remove-targets) 1)) - (command-core/invalid-options-result summary "only one of --id, --uuid, or --page is allowed") + (and (= command :remove-page) (not (seq (some-> (:name opts) string/trim)))) + (missing-page-name-result summary) + + (and (#{:remove-tag :remove-property} command) + (empty? (filter some? [(:id opts) (some-> (:name opts) string/trim)]))) + (missing-target-result summary) (and (= command :update-block) (update-command/invalid-options? opts)) (command-core/invalid-options-result summary (update-command/invalid-options? opts)) @@ -207,8 +220,9 @@ (list-command/invalid-options? command opts)) (command-core/invalid-options-result summary (list-command/invalid-options? command opts)) - (and (= command :remove) (remove-command/invalid-options? opts)) - (command-core/invalid-options-result summary (remove-command/invalid-options? opts)) + (and (#{:remove-block :remove-page :remove-tag :remove-property} command) + (remove-command/invalid-options? command opts)) + (command-core/invalid-options-result summary (remove-command/invalid-options? command opts)) (and (= command :show) (show-command/invalid-options? opts)) (command-core/invalid-options-result summary (show-command/invalid-options? opts)) @@ -268,7 +282,7 @@ :message "missing command"} :summary summary}) - (and (= 1 (count args)) (#{"graph" "server" "list" "add" "query"} (first args))) + (and (= 1 (count args)) (#{"graph" "server" "list" "add" "upsert" "remove" "query"} (first args))) (command-core/help-result (command-core/group-summary (first args) table)) :else @@ -372,14 +386,17 @@ :add-page (add-command/build-add-page-action options repo) - :add-tag - (add-command/build-add-tag-action options repo) + :upsert-tag + (upsert-command/build-tag-action options repo) + + :upsert-property + (upsert-command/build-property-action options repo) :update-block (update-command/build-action options repo) - :remove - (remove-command/build-action options repo) + (:remove-block :remove-page :remove-tag :remove-property) + (remove-command/build-action command options repo) :query (query-command/build-action options repo config) @@ -423,9 +440,13 @@ :list-property (list-command/execute-list-property action config) :add-block (add-command/execute-add-block action config) :add-page (add-command/execute-add-page action config) - :add-tag (add-command/execute-add-tag action config) + :upsert-tag (upsert-command/execute-upsert-tag action config) + :upsert-property (upsert-command/execute-upsert-property action config) :update-block (update-command/execute-update action config) - :remove (remove-command/execute-remove action config) + :remove-block (remove-command/execute-remove-block action config) + :remove-page (remove-command/execute-remove-page action config) + :remove-tag (remove-command/execute-remove-tag action config) + :remove-property (remove-command/execute-remove-property action config) :query (query-command/execute-query action config) :query-list (query-command/execute-query-list action config) :show (show-command/execute-show action config) @@ -440,8 +461,8 @@ :message "unknown action"}}))] (assoc result :command (or (:command action) (:type action)) - :context (select-keys action [:repo :graph :page :id :ids :uuid :block :blocks - :name + :context (select-keys action [:repo :graph :page :name :id :ids :uuid :block :blocks + :schema :source :target :update-tags :update-properties :remove-tags :remove-properties :export-type :output :import-type :input]))))) diff --git a/src/main/logseq/cli/format.cljs b/src/main/logseq/cli/format.cljs index 3ff9292e45..6052212040 100644 --- a/src/main/logseq/cli/format.cljs +++ b/src/main/logseq/cli/format.cljs @@ -93,17 +93,30 @@ :missing-tag-name "Use --name " :missing-query "Use --query " :unknown-query "Use `logseq query list` to see available queries" + :ambiguous-tag-name "Retry with --id " + :ambiguous-property-name "Retry with --id " :data-dir-permission "Check filesystem permissions or set LOGSEQ_CLI_DATA_DIR" :server-owned-by-other "Retry from the process owner that started the server" :server-start-timeout-orphan "Check and stop lingering db-worker-node processes, then retry" nil)) +(defn- format-candidates + [candidates] + (when (seq candidates) + (str "\nCandidates:\n" + (string/join "\n" + (map (fn [{:keys [id name]}] + (str " " id " " (or name "-"))) + candidates))))) + (defn- format-error [error] - (let [{:keys [code message]} error + (let [{:keys [code message candidates]} error hint (error-hint error) - message* (style/bold-keywords message ["option" "command" "argument"])] + message* (style/bold-keywords message ["option" "command" "argument"]) + candidates* (format-candidates candidates)] (cond-> (str "Error (" (name (or code :error)) "): " message*) + candidates* (str candidates*) hint (str "\nHint: " hint)))) (defn- maybe-ident-header @@ -248,14 +261,37 @@ [_context ids] (str "Added tag:\n" (pr-str (vec (or ids []))))) -(defn- format-remove - [{:keys [repo page uuid id ids]}] +(defn- format-upsert-tag + [_context ids] + (str "Upserted tag:\n" (pr-str (vec (or ids []))))) + +(defn- format-upsert-property + [_context ids] + (str "Upserted property:\n" (pr-str (vec (or ids []))))) + +(defn- format-remove-block + [{:keys [repo uuid id ids]}] (cond - (seq page) (str "Removed page: " page " (repo: " repo ")") (seq uuid) (str "Removed block: " uuid " (repo: " repo ")") (seq ids) (str "Removed blocks: " (count ids) " (repo: " repo ")") (some? id) (str "Removed block: " id " (repo: " repo ")") - :else (str "Removed item (repo: " repo ")"))) + :else (str "Removed block (repo: " repo ")"))) + +(defn- format-remove-page + [{:keys [repo name]}] + (str "Removed page: " name " (repo: " repo ")")) + +(defn- format-remove-tag + [{:keys [repo name id]}] + (if (seq name) + (str "Removed tag: " name " (repo: " repo ")") + (str "Removed tag: " id " (repo: " repo ")"))) + +(defn- format-remove-property + [{:keys [repo name id]}] + (if (seq name) + (str "Removed property: " name " (repo: " repo ")") + (str "Removed property: " id " (repo: " repo ")"))) (defn- format-update-block [{:keys [repo source target update-tags update-properties remove-tags remove-properties]}] @@ -319,7 +355,12 @@ :add-block (format-add-block context (:result data)) :add-page (format-add-page context (:result data)) :add-tag (format-add-tag context (:result data)) - :remove (format-remove context) + :upsert-tag (format-upsert-tag context (:result data)) + :upsert-property (format-upsert-property context (:result data)) + :remove-block (format-remove-block context) + :remove-page (format-remove-page context) + :remove-tag (format-remove-tag context) + :remove-property (format-remove-property context) :update-block (format-update-block context) :graph-export (format-graph-export context) :graph-import (format-graph-import context) diff --git a/src/test/logseq/cli/commands_test.cljs b/src/test/logseq/cli/commands_test.cljs index 252997f9e9..bbbafd346f 100644 --- a/src/test/logseq/cli/commands_test.cljs +++ b/src/test/logseq/cli/commands_test.cljs @@ -60,6 +60,7 @@ (is (string/includes? plain-summary "Graph Management")) (is (string/includes? plain-summary "list")) (is (string/includes? plain-summary "add")) + (is (string/includes? plain-summary "upsert")) (is (string/includes? plain-summary "remove")) (is (string/includes? plain-summary "update")) (is (string/includes? plain-summary "query")) @@ -73,8 +74,12 @@ (is (contains-bold? summary "list property")) (is (contains-bold? summary "add block")) (is (contains-bold? summary "add page")) - (is (contains-bold? summary "add tag")) - (is (contains-bold? summary "remove")) + (is (contains-bold? summary "upsert tag")) + (is (contains-bold? summary "upsert property")) + (is (contains-bold? summary "remove block")) + (is (contains-bold? summary "remove page")) + (is (contains-bold? summary "remove tag")) + (is (contains-bold? summary "remove property")) (is (contains-bold? summary "update")) (is (contains-bold? summary "query")) (is (contains-bold? summary "query list")) @@ -134,17 +139,27 @@ (is (string/includes? plain-summary "Global options:")) (is (string/includes? plain-summary "Command options:")))) - (testing "remove command shows help" + (testing "upsert group shows subcommands" (let [result (binding [style/*color-enabled?* true] - (commands/parse-args ["remove" "--help"])) + (commands/parse-args ["upsert"])) summary (:summary result) plain-summary (strip-ansi summary)] (is (true? (:help? result))) - (is (string/includes? plain-summary "Usage: logseq remove")) + (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 "remove block command shows help" + (let [result (binding [style/*color-enabled?* true] + (commands/parse-args ["remove" "block" "--help"])) + summary (:summary result) + plain-summary (strip-ansi summary)] + (is (true? (:help? result))) + (is (string/includes? plain-summary "Usage: logseq remove block")) (is (string/includes? plain-summary "Command options:")) (is (contains-bold? summary "--id")) - (is (contains-bold? summary "--uuid")) - (is (contains-bold? summary "--page")))) + (is (contains-bold? summary "--uuid")))) (testing "update command shows help" (let [result (binding [style/*color-enabled?* true] @@ -192,7 +207,7 @@ (is (seq lines)) (is (every? #(not (string/includes? % "[options]")) lines))))) -(deftest test-parse-args-help-add-group +(deftest test-parse-args-help-add-upsert-group (testing "add group shows subcommands" (let [result (binding [style/*color-enabled?* true] (commands/parse-args ["add"])) @@ -201,10 +216,19 @@ (is (true? (:help? result))) (is (string/includes? plain-summary "add block")) (is (string/includes? plain-summary "add page")) - (is (string/includes? plain-summary "add tag")) (is (contains-bold? summary "add block")) - (is (contains-bold? summary "add page")) - (is (contains-bold? summary "add tag"))))) + (is (contains-bold? summary "add page")))) + + (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"))))) (deftest test-parse-args-help-alignment (testing "graph group aligns subcommand columns" @@ -268,13 +292,26 @@ (is (false? (:ok? result))) (is (= :unknown-command (get-in result [:error :code])))))) -(deftest test-parse-args-rejects-legacy-remove-subcommands - (testing "rejects legacy remove subcommands" - (doseq [args [["remove" "block"] - ["remove" "page"]]] - (let [result (commands/parse-args args)] - (is (false? (:ok? result))) - (is (= :invalid-options (get-in result [:error :code]))))))) +(deftest test-parse-args-remove-help-and-rejects-add-tag + (testing "bare remove shows remove subcommand help" + (let [result (binding [style/*color-enabled?* true] + (commands/parse-args ["remove"])) + summary (:summary result) + plain-summary (strip-ansi summary)] + (is (true? (:help? result))) + (is (string/includes? plain-summary "remove block")) + (is (string/includes? plain-summary "remove page")) + (is (string/includes? plain-summary "remove tag")) + (is (string/includes? plain-summary "remove property")) + (is (contains-bold? summary "remove block")) + (is (contains-bold? summary "remove page")) + (is (contains-bold? summary "remove tag")) + (is (contains-bold? summary "remove property")))) + + (testing "rejects removed add tag command" + (let [result (commands/parse-args ["add" "tag" "--name" "Quote"])] + (is (false? (:ok? result))) + (is (= :unknown-command (get-in result [:error :code])))))) (deftest test-parse-args-rejects-graph-option (testing "rejects legacy --graph option" @@ -826,42 +863,69 @@ (is (false? (:ok? result))) (is (= :invalid-options (get-in result [:error :code])))))) -(deftest test-verb-subcommand-parse-add-remove - (testing "remove requires target" - (let [result (commands/parse-args ["remove"])] - (is (false? (:ok? result))) - (is (= :missing-target (get-in result [:error :code]))))) - - (testing "remove parses with id" - (let [result (commands/parse-args ["remove" "--id" "10"])] +(deftest test-verb-subcommand-parse-upsert-remove + (testing "remove block parses with id" + (let [result (commands/parse-args ["remove" "block" "--id" "10"])] (is (true? (:ok? result))) - (is (= :remove (:command result))) + (is (= :remove-block (:command result))) (is (= 10 (get-in result [:options :id]))))) - (testing "remove parses with uuid" - (let [result (commands/parse-args ["remove" "--uuid" "abc"])] + (testing "remove page parses with name" + (let [result (commands/parse-args ["remove" "page" "--name" "Home"])] (is (true? (:ok? result))) - (is (= :remove (:command result))) - (is (= "abc" (get-in result [:options :uuid]))))) + (is (= :remove-page (:command result))) + (is (= "Home" (get-in result [:options :name]))))) - (testing "remove parses with page" - (let [result (commands/parse-args ["remove" "--page" "Home"])] + (testing "remove tag parses with name" + (let [result (commands/parse-args ["remove" "tag" "--name" "Quote"])] (is (true? (:ok? result))) - (is (= :remove (:command result))) - (is (= "Home" (get-in result [:options :page]))))) + (is (= :remove-tag (:command result))) + (is (= "Quote" (get-in result [:options :name]))))) - (testing "remove rejects multiple selectors" - (let [result (commands/parse-args ["remove" "--id" "1" "--page" "Home"])] + (testing "remove property parses with id" + (let [result (commands/parse-args ["remove" "property" "--id" "123"])] + (is (true? (:ok? result))) + (is (= :remove-property (:command result))) + (is (= 123 (get-in result [:options :id]))))) + + (testing "remove block rejects empty id vector" + (let [result (commands/parse-args ["remove" "block" "--id" "[]"])] (is (false? (:ok? result))) (is (= :invalid-options (get-in result [:error :code]))))) - (testing "remove rejects empty id vector" - (let [result (commands/parse-args ["remove" "--id" "[]"])] + (testing "remove block rejects invalid id vector" + (let [result (commands/parse-args ["remove" "block" "--id" "[1 \"no\"]"])] (is (false? (:ok? result))) (is (= :invalid-options (get-in result [:error :code]))))) - (testing "remove rejects invalid id vector" - (let [result (commands/parse-args ["remove" "--id" "[1 \"no\"]"])] + (testing "upsert tag parses with name" + (let [result (commands/parse-args ["upsert" "tag" "--name" "Quote"])] + (is (true? (:ok? result))) + (is (= :upsert-tag (:command result))) + (is (= "Quote" (get-in result [:options :name]))))) + + (testing "upsert property parses with type and cardinality" + (let [result (commands/parse-args ["upsert" "property" + "--name" "owner" + "--type" "node" + "--cardinality" "many"])] + (is (true? (:ok? result))) + (is (= :upsert-property (:command result))) + (is (= "owner" (get-in result [:options :name]))) + (is (= "node" (get-in result [:options :type]))) + (is (= "many" (get-in result [:options :cardinality]))))) + + (testing "upsert property rejects invalid type" + (let [result (commands/parse-args ["upsert" "property" + "--name" "owner" + "--type" "wat"])] + (is (false? (:ok? result))) + (is (= :invalid-options (get-in result [:error :code]))))) + + (testing "upsert property rejects invalid cardinality" + (let [result (commands/parse-args ["upsert" "property" + "--name" "owner" + "--cardinality" "triple"])] (is (false? (:ok? result))) (is (= :invalid-options (get-in result [:error :code]))))) @@ -973,21 +1037,10 @@ (is (= "[\"TagA\"]" (get-in result [:options :tags]))) (is (= "{:logseq.property/publishing-public? true}" (get-in result [:options :properties]))))) - (testing "add tag requires name" - (let [result (commands/parse-args ["add" "tag"])] - (is (false? (:ok? result))) - (is (= :missing-tag-name (get-in result [:error :code]))))) - - (testing "add tag parses with name" + (testing "add tag is no longer supported" (let [result (commands/parse-args ["add" "tag" "--name" "Quote"])] - (is (true? (:ok? result))) - (is (= :add-tag (:command result))) - (is (= "Quote" (get-in result [:options :name]))))) - - (testing "add tag rejects blank name" - (let [result (commands/parse-args ["add" "tag" "--name" " "])] (is (false? (:ok? result))) - (is (= :missing-tag-name (get-in result [:error :code])))))) + (is (= :unknown-command (get-in result [:error :code])))))) (deftest test-verb-subcommand-parse-update-target-page (testing "update parses with target page" @@ -1113,7 +1166,8 @@ (testing "verb subcommands reject unknown flags" (doseq [args [["list" "page" "--wat"] ["add" "block" "--wat"] - ["remove" "--wat"] + ["remove" "block" "--wat"] + ["upsert" "tag" "--wat"] ["update" "--wat"] ["show" "--wat"]]] (let [result (commands/parse-args args)] @@ -1207,7 +1261,7 @@ (is (= (cli-server/db-worker-dev-script-path) (get-in result [:action :script-path])))))) -(deftest test-build-action-inspect-edit +(deftest 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 {})] @@ -1232,35 +1286,79 @@ (is (false? (:ok? result))) (is (= :missing-page-name (get-in result [:error :code]))))) - (testing "add tag requires name" - (let [parsed {:ok? true :command :add-tag :options {}} + (testing "upsert tag requires name" + (let [parsed {:ok? true :command :upsert-tag :options {}} result (commands/build-action parsed {:repo "demo"})] (is (false? (:ok? result))) (is (= :missing-tag-name (get-in result [:error :code]))))) - (testing "add tag builds normalized action" - (let [parsed {:ok? true :command :add-tag :options {:name " #Quote "}} + (testing "upsert tag builds normalized action" + (let [parsed {:ok? true :command :upsert-tag :options {:name " #Quote "}} result (commands/build-action parsed {:repo "demo"})] (is (true? (:ok? result))) - (is (= {:type :add-tag + (is (= {:type :upsert-tag :repo "logseq_db_demo" :graph "demo" :name "Quote"} (:action result))))) - (testing "remove requires target" - (let [parsed {:ok? true :command :remove :options {}} + (testing "upsert property coerces schema options" + (let [parsed {:ok? true + :command :upsert-property + :options {:name "owner" + :type "node" + :cardinality "many" + :hide true + :public false}} + result (commands/build-action parsed {:repo "demo"})] + (is (true? (:ok? result))) + (is (= {:type :upsert-property + :repo "logseq_db_demo" + :graph "demo" + :name "owner" + :schema {:logseq.property/type :node + :db/cardinality :db.cardinality/many + :logseq.property/hide? true + :logseq.property/public? false}} + (:action result))))) + + ) + +(deftest test-build-action-inspect-edit-remove-show + + (testing "remove block requires target" + (let [parsed {:ok? true :command :remove-block :options {}} result (commands/build-action parsed {:repo "demo"})] (is (false? (:ok? result))) (is (= :missing-target (get-in result [:error :code]))))) - (testing "remove normalizes id vector in build action" - (let [parsed {:ok? true :command :remove :options {:id "[1 2]"}} + (testing "remove block normalizes id vector in build action" + (let [parsed {:ok? true :command :remove-block :options {:id "[1 2]"}} result (commands/build-action parsed {:repo "demo"})] (is (true? (:ok? result))) - (is (= :remove (get-in result [:action :type]))) + (is (= :remove-block (get-in result [:action :type]))) (is (= [1 2] (get-in result [:action :ids]))))) + (testing "remove page requires name" + (let [parsed {:ok? true :command :remove-page :options {}} + result (commands/build-action parsed {:repo "demo"})] + (is (false? (:ok? result))) + (is (= :missing-page-name (get-in result [:error :code]))))) + + (testing "remove tag parses by id" + (let [parsed {:ok? true :command :remove-tag :options {:id 42}} + result (commands/build-action parsed {:repo "demo"})] + (is (true? (:ok? result))) + (is (= :remove-tag (get-in result [:action :type]))) + (is (= 42 (get-in result [:action :id]))))) + + (testing "remove property parses by name" + (let [parsed {:ok? true :command :remove-property :options {:name "owner"}} + result (commands/build-action parsed {:repo "demo"})] + (is (true? (:ok? result))) + (is (= :remove-property (get-in result [:action :type]))) + (is (= "owner" (get-in result [:action :name]))))) + (testing "show requires target" (let [parsed {:ok? true :command :show :options {}} result (commands/build-action parsed {:repo "demo"})] @@ -1386,15 +1484,17 @@ (is (false? (:ok? result))) (is (= :invalid-options (get-in result [:error :code])))))) -(deftest test-execute-add-tag-builds-create-page-op +(deftest test-execute-upsert-tag-builds-create-page-op (async done (let [ops* (atom nil) created?* (atom false) + orig-list-graphs cli-server/list-graphs orig-ensure-server! cli-server/ensure-server! orig-invoke transport/invoke - action {:type :add-tag + action {:type :upsert-tag :repo "demo" :name "Quote"}] + (set! cli-server/list-graphs (fn [_] ["demo"])) (set! cli-server/ensure-server! (fn [_ _] {:base-url "http://example"})) (set! transport/invoke (fn [_ method _ args] (case method @@ -1412,7 +1512,7 @@ (reset! ops* ops) {:result :ok}) (throw (ex-info "unexpected invoke" {:method method :args args}))))) - (-> (p/let [result (add-command/execute-add-tag action {})] + (-> (p/let [result (commands/execute action {})] (is (= :ok (:status result))) (is (= [4242] (get-in result [:data :result]))) (is (= [[:create-page ["Quote" {:class? true}]]] @@ -1420,17 +1520,20 @@ (p/catch (fn [e] (is false (str "unexpected error: " e)))) (p/finally (fn [] + (set! cli-server/list-graphs orig-list-graphs) (set! cli-server/ensure-server! orig-ensure-server!) (set! transport/invoke orig-invoke) (done))))))) -(deftest test-execute-add-tag-rejects-existing-non-tag-page +(deftest test-execute-upsert-tag-rejects-existing-non-tag-page (async done - (let [action {:type :add-tag + (let [action {:type :upsert-tag :repo "demo" :name "Home"} + orig-list-graphs cli-server/list-graphs orig-ensure-server! cli-server/ensure-server! orig-invoke transport/invoke] + (set! cli-server/list-graphs (fn [_] ["demo"])) (set! cli-server/ensure-server! (fn [_ _] {:base-url "http://example"})) (set! transport/invoke (fn [_ method _ args] (case method @@ -1444,24 +1547,27 @@ :thread-api/apply-outliner-ops (throw (ex-info "should not create tag" {:args args})) (throw (ex-info "unexpected invoke" {:method method :args args}))))) - (-> (add-command/execute-add-tag action {}) - (p/then (fn [_] - (is false "expected add tag conflict error"))) + (-> (p/let [result (commands/execute action {})] + (is (= :error (:status result))) + (is (= :tag-name-conflict (get-in result [:error :code])))) (p/catch (fn [e] - (is (= :tag-name-conflict (:code (ex-data e)))))) + (is false (str "unexpected error: " e)))) (p/finally (fn [] + (set! cli-server/list-graphs orig-list-graphs) (set! cli-server/ensure-server! orig-ensure-server!) (set! transport/invoke orig-invoke) (done))))))) -(deftest test-execute-add-tag-idempotent-when-tag-exists +(deftest test-execute-upsert-tag-idempotent-when-tag-exists (async done (let [apply-calls* (atom 0) + orig-list-graphs cli-server/list-graphs orig-ensure-server! cli-server/ensure-server! orig-invoke transport/invoke - action {:type :add-tag + action {:type :upsert-tag :repo "demo" :name "Quote"}] + (set! cli-server/list-graphs (fn [_] ["demo"])) (set! cli-server/ensure-server! (fn [_ _] {:base-url "http://example"})) (set! transport/invoke (fn [_ method _ args] (case method @@ -1476,13 +1582,159 @@ (swap! apply-calls* inc) {:result :ok}) (throw (ex-info "unexpected invoke" {:method method :args args}))))) - (-> (p/let [result (add-command/execute-add-tag action {})] + (-> (p/let [result (commands/execute action {})] (is (= :ok (:status result))) (is (= [4242] (get-in result [:data :result]))) (is (= 0 @apply-calls*))) (p/catch (fn [e] (is false (str "unexpected error: " e)))) (p/finally (fn [] + (set! cli-server/list-graphs orig-list-graphs) + (set! cli-server/ensure-server! orig-ensure-server!) + (set! transport/invoke orig-invoke) + (done))))))) + +(deftest test-execute-upsert-property-emits-upsert-op + (async done + (let [ops* (atom nil) + created?* (atom false) + orig-list-graphs cli-server/list-graphs + orig-ensure-server! cli-server/ensure-server! + orig-invoke transport/invoke + action {:type :upsert-property + :repo "demo" + :name "owner" + :schema {:logseq.property/type :node + :db/cardinality :db.cardinality/many}}] + (set! cli-server/list-graphs (fn [_] ["demo"])) + (set! cli-server/ensure-server! (fn [_ _] {:base-url "http://example"})) + (set! transport/invoke (fn [_ method _ args] + (case method + :thread-api/pull (if @created?* + {:db/id 654 + :db/ident :user.property/owner + :block/name "owner" + :block/title "owner" + :logseq.property/type :node} + {}) + :thread-api/apply-outliner-ops (let [[_ ops _] args] + (reset! created?* true) + (reset! ops* ops) + {:result :ok}) + (throw (ex-info "unexpected invoke" {:method method :args args}))))) + (-> (p/let [result (commands/execute action {})] + (is (= :ok (:status result))) + (is (= [[:upsert-property [nil + {:logseq.property/type :node + :db/cardinality :db.cardinality/many} + {:property-name "owner"}]]] + @ops*))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally (fn [] + (set! cli-server/list-graphs orig-list-graphs) + (set! cli-server/ensure-server! orig-ensure-server!) + (set! transport/invoke orig-invoke) + (done))))))) + +(deftest test-execute-remove-tag-property + (async done + (let [ops* (atom []) + orig-list-graphs cli-server/list-graphs + orig-ensure-server! cli-server/ensure-server! + orig-invoke transport/invoke] + (set! cli-server/list-graphs (fn [_] ["demo"])) + (set! cli-server/ensure-server! (fn [_ _] {:base-url "http://example"})) + (set! transport/invoke + (fn [_ method _ args] + (case method + :thread-api/api-list-tags [{:db/id 1 :block/title "Quote"}] + :thread-api/api-list-properties [{:db/id 2 :block/title "owner"}] + :thread-api/pull (let [[_ selector lookup] args] + (cond + (= lookup 1) + {:db/id 1 + :block/title "Quote" + :block/uuid (uuid "00000000-0000-0000-0000-000000000011") + :block/tags [{:db/ident :logseq.class/Tag}] + :logseq.property/public? true} + + (= lookup 2) + {:db/id 2 + :db/ident :user.property/owner + :block/title "owner" + :block/uuid (uuid "00000000-0000-0000-0000-000000000022") + :logseq.property/type :node + :logseq.property/public? true} + + (= lookup [:block/name "quote"]) + {:db/id 1 + :block/title "Quote" + :block/uuid (uuid "00000000-0000-0000-0000-000000000011") + :block/tags [{:db/ident :logseq.class/Tag}] + :logseq.property/public? true} + + (= lookup [:block/name "owner"]) + {:db/id 2 + :db/ident :user.property/owner + :block/title "owner" + :block/uuid (uuid "00000000-0000-0000-0000-000000000022") + :logseq.property/type :node + :logseq.property/public? true} + + :else + (throw (ex-info "unexpected pull lookup" + {:lookup lookup :selector selector})))) + :thread-api/apply-outliner-ops (let [[_ ops _] args] + (swap! ops* conj ops) + {:result :ok}) + (throw (ex-info "unexpected invoke" {:method method :args args}))))) + (-> (p/let [tag-result (commands/execute {:type :remove-tag + :repo "demo" + :name "Quote"} + {}) + property-result (commands/execute {:type :remove-property + :repo "demo" + :id 2} + {})] + (is (= :ok (:status tag-result))) + (is (= :ok (:status property-result))) + (is (= [[:delete-page [(uuid "00000000-0000-0000-0000-000000000011")]]] + (first @ops*))) + (is (= [[:delete-page [(uuid "00000000-0000-0000-0000-000000000022")]]] + (second @ops*)))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally (fn [] + (set! cli-server/list-graphs orig-list-graphs) + (set! cli-server/ensure-server! orig-ensure-server!) + (set! transport/invoke orig-invoke) + (done))))))) + +(deftest test-execute-remove-tag-ambiguous-name + (async done + (let [orig-list-graphs cli-server/list-graphs + orig-ensure-server! cli-server/ensure-server! + orig-invoke transport/invoke] + (set! cli-server/list-graphs (fn [_] ["demo"])) + (set! cli-server/ensure-server! (fn [_ _] {:base-url "http://example"})) + (set! transport/invoke + (fn [_ method _ _] + (case method + :thread-api/api-list-tags [{:db/id 1 :block/title "Quote"} + {:db/id 2 :block/title "QUOTE"}] + (throw (ex-info "unexpected invoke" {:method method}))))) + (-> (p/let [result (commands/execute {:type :remove-tag + :repo "demo" + :name "Quote"} + {})] + (is (= :error (:status result))) + (is (= :ambiguous-tag-name (get-in result [:error :code]))) + (is (= 2 (count (get-in result [:error :candidates]))))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally (fn [] + (set! cli-server/list-graphs orig-list-graphs) (set! cli-server/ensure-server! orig-ensure-server!) (set! transport/invoke orig-invoke) (done))))))) diff --git a/src/test/logseq/cli/format_test.cljs b/src/test/logseq/cli/format_test.cljs index d6b0601446..ff794149f7 100644 --- a/src/test/logseq/cli/format_test.cljs +++ b/src/test/logseq/cli/format_test.cljs @@ -90,7 +90,7 @@ "Count: 1") result))))) -(deftest test-human-output-add-remove +(deftest test-human-output-add-upsert-remove (testing "add block renders ids in two lines" (let [result (format/format-result {:status :ok :command :add-block @@ -109,24 +109,60 @@ {:output-format nil})] (is (= "Added page:\n[123]" result)))) - (testing "add tag renders ids in two lines" + (testing "upsert tag renders ids in two lines" (let [result (format/format-result {:status :ok - :command :add-tag + :command :upsert-tag :context {:repo "demo-repo" :name "Quote"} :data {:result [321]}} {:output-format nil})] - (is (= "Added tag:\n[321]" result)))) + (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 + :command :remove-page :context {:repo "demo-repo" - :page "Home"} + :name "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 "update block renders a succinct success line" (let [result (format/format-result {:status :ok :command :update-block @@ -430,7 +466,22 @@ {:output-format nil})] (is (= (str "Error (server-start-timeout-orphan): db-worker-node failed to create lock\n" "Hint: Check and stop lingering db-worker-node processes, then retry") - result))))) + result)))) + + (testing "remove tag ambiguity includes candidate list" + (let [result (format/format-result {:status :error + :command :remove-tag + :error {:code :ambiguous-tag-name + :message "multiple tags match name: Quote" + :candidates [{:id 1 :name "Quote"} + {:id 2 :name "QUOTE"}]}} + {:output-format nil})] + (is (string/includes? result "Error (ambiguous-tag-name):")) + (is (string/includes? result "multiple tags match name: Quote")) + (is (string/includes? result "1")) + (is (string/includes? result "2")) + (is (string/includes? result "Quote")) + (is (string/includes? result "QUOTE"))))) (deftest test-human-output-doctor (testing "doctor renders concise check summary" diff --git a/src/test/logseq/cli/integration_test.cljs b/src/test/logseq/cli/integration_test.cljs index 36f9aa0ed9..dec868d709 100644 --- a/src/test/logseq/cli/integration_test.cljs +++ b/src/test/logseq/cli/integration_test.cljs @@ -282,7 +282,7 @@ _ (p/delay 100) show-result (run-cli ["--repo" "content-graph" "show" "--page" "TestPage"] data-dir cfg-path) show-payload (parse-json-output show-result) - remove-page-result (run-cli ["--repo" "content-graph" "remove" "--page" "TestPage"] data-dir cfg-path) + remove-page-result (run-cli ["--repo" "content-graph" "remove" "page" "--name" "TestPage"] data-dir cfg-path) remove-page-payload (parse-json-output remove-page-result) stop-result (run-cli ["server" "stop" "--repo" "content-graph"] data-dir cfg-path) stop-payload (parse-json-output stop-result)] @@ -437,7 +437,7 @@ "--update-properties" "{:logseq.property/publishing-public? true}"] data-dir cfg-path) update-payload (parse-json-output update-result) - remove-result (run-cli ["--repo" repo "remove" "--id" (str block-id)] data-dir cfg-path) + remove-result (run-cli ["--repo" repo "remove" "block" "--id" (str block-id)] data-dir cfg-path) remove-payload (parse-json-output remove-result) query-after-remove (run-query data-dir cfg-path repo "[:find ?e . :in $ ?title :where [?e :block/title ?title]]" @@ -857,19 +857,19 @@ (is false (str "unexpected error: " e)) (done))))))) -(deftest ^:long test-cli-add-tag-create-and-use +(deftest ^:long test-cli-upsert-tag-create-and-use (async done - (let [data-dir (node-helper/create-tmp-dir "db-worker-add-tag-create")] + (let [data-dir (node-helper/create-tmp-dir "db-worker-upsert-tag-create")] (-> (p/let [cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn") - repo "add-tag-create-graph" + repo "upsert-tag-create-graph" _ (fs/writeFileSync cfg-path "{:output-format :json}") _ (run-cli ["graph" "create" "--repo" repo] data-dir cfg-path) _ (run-cli ["--repo" repo "add" "page" "--page" "Home"] data-dir cfg-path) - add-tag-result (run-cli ["--repo" repo - "add" "tag" - "--name" "CliQuote"] - data-dir cfg-path) - add-tag-payload (parse-json-output add-tag-result) + upsert-tag-result (run-cli ["--repo" repo + "upsert" "tag" + "--name" "CliQuote"] + data-dir cfg-path) + upsert-tag-payload (parse-json-output upsert-tag-result) list-tag-result (run-cli ["--repo" repo "list" "tag"] data-dir cfg-path) list-tag-payload (parse-json-output list-tag-result) tag-names (->> (get-in list-tag-payload [:data :items]) @@ -878,17 +878,17 @@ add-block-result (run-cli ["--repo" repo "add" "block" "--target-page-name" "Home" - "--content" "Tagged by add tag" + "--content" "Tagged by upsert tag" "--tags" "[\"CliQuote\"]"] data-dir cfg-path) add-block-payload (parse-json-output add-block-result) _ (p/delay 100) - block-tag-names (query-tags data-dir cfg-path repo "Tagged by add tag") + block-tag-names (query-tags data-dir cfg-path repo "Tagged by upsert tag") stop-result (run-cli ["server" "stop" "--repo" repo] data-dir cfg-path) stop-payload (parse-json-output stop-result)] - (is (= 0 (:exit-code add-tag-result)) - (pr-str (:error add-tag-payload))) - (is (= "ok" (:status add-tag-payload))) + (is (= 0 (:exit-code upsert-tag-result)) + (pr-str (:error upsert-tag-payload))) + (is (= "ok" (:status upsert-tag-payload))) (is (= "ok" (:status list-tag-payload))) (is (contains? tag-names "CliQuote")) (is (= 0 (:exit-code add-block-result)) @@ -901,19 +901,19 @@ (is false (str "unexpected error: " e)) (done))))))) -(deftest ^:long test-cli-add-tag-rejects-existing-non-tag-page +(deftest ^:long test-cli-upsert-tag-rejects-existing-non-tag-page (async done - (let [data-dir (node-helper/create-tmp-dir "db-worker-add-tag-conflict")] + (let [data-dir (node-helper/create-tmp-dir "db-worker-upsert-tag-conflict")] (-> (p/let [cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn") - repo "add-tag-conflict-graph" + repo "upsert-tag-conflict-graph" _ (fs/writeFileSync cfg-path "{:output-format :json}") _ (run-cli ["graph" "create" "--repo" repo] data-dir cfg-path) _ (run-cli ["--repo" repo "add" "page" "--page" "ConflictPage"] data-dir cfg-path) - add-tag-result (run-cli ["--repo" repo - "add" "tag" - "--name" "ConflictPage"] - data-dir cfg-path) - add-tag-payload (parse-json-output add-tag-result) + upsert-tag-result (run-cli ["--repo" repo + "upsert" "tag" + "--name" "ConflictPage"] + data-dir cfg-path) + upsert-tag-payload (parse-json-output upsert-tag-result) list-tag-result (run-cli ["--repo" repo "list" "tag"] data-dir cfg-path) list-tag-payload (parse-json-output list-tag-result) tag-names (->> (get-in list-tag-payload [:data :items]) @@ -921,9 +921,9 @@ set) stop-result (run-cli ["server" "stop" "--repo" repo] data-dir cfg-path) stop-payload (parse-json-output stop-result)] - (is (= 1 (:exit-code add-tag-result))) - (is (= "error" (:status add-tag-payload))) - (is (string/includes? (get-in add-tag-payload [:error :message]) + (is (= 0 (:exit-code upsert-tag-result))) + (is (= "error" (:status upsert-tag-payload))) + (is (string/includes? (get-in upsert-tag-payload [:error :message]) "already exists as a page and is not a tag")) (is (not (contains? tag-names "ConflictPage"))) (is (= "ok" (:status stop-payload))) @@ -932,29 +932,29 @@ (is false (str "unexpected error: " e)) (done))))))) -(deftest ^:long test-cli-add-tag-idempotent-existing-tag +(deftest ^:long test-cli-upsert-tag-idempotent-existing-tag (async done - (let [data-dir (node-helper/create-tmp-dir "db-worker-add-tag-idempotent")] + (let [data-dir (node-helper/create-tmp-dir "db-worker-upsert-tag-idempotent")] (-> (p/let [cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn") - repo "add-tag-idempotent-graph" + repo "upsert-tag-idempotent-graph" _ (fs/writeFileSync cfg-path "{:output-format :json}") _ (run-cli ["graph" "create" "--repo" repo] data-dir cfg-path) - first-add-result (run-cli ["--repo" repo "add" "tag" "--name" "StableTag"] - data-dir cfg-path) - first-add-payload (parse-json-output first-add-result) - second-add-result (run-cli ["--repo" repo "add" "tag" "--name" "StableTag"] - data-dir cfg-path) - second-add-payload (parse-json-output second-add-result) + first-upsert-result (run-cli ["--repo" repo "upsert" "tag" "--name" "StableTag"] + data-dir cfg-path) + first-upsert-payload (parse-json-output first-upsert-result) + second-upsert-result (run-cli ["--repo" repo "upsert" "tag" "--name" "StableTag"] + data-dir cfg-path) + second-upsert-payload (parse-json-output second-upsert-result) list-tag-result (run-cli ["--repo" repo "list" "tag"] data-dir cfg-path) list-tag-payload (parse-json-output list-tag-result) stable-tags (->> (get-in list-tag-payload [:data :items]) (filter #(= "StableTag" (or (:block/title %) (:title %) (:name %))))) stop-result (run-cli ["server" "stop" "--repo" repo] data-dir cfg-path) stop-payload (parse-json-output stop-result)] - (is (= 0 (:exit-code first-add-result))) - (is (= "ok" (:status first-add-payload))) - (is (= 0 (:exit-code second-add-result))) - (is (= "ok" (:status second-add-payload))) + (is (= 0 (:exit-code first-upsert-result))) + (is (= "ok" (:status first-upsert-payload))) + (is (= 0 (:exit-code second-upsert-result))) + (is (= "ok" (:status second-upsert-payload))) (is (= 1 (count stable-tags))) (is (= "ok" (:status stop-payload))) (done)) @@ -962,6 +962,73 @@ (is false (str "unexpected error: " e)) (done))))))) +(deftest ^:long test-cli-upsert-and-remove-tag-property + (async done + (let [data-dir (node-helper/create-tmp-dir "db-worker-upsert-remove-tag-property")] + (-> (p/let [cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn") + repo "upsert-remove-tag-property-graph" + tag-name "CliQuoteTagX" + property-name "CliOwnerPropX" + property-name-lc (common-util/page-name-sanity-lc property-name) + _ (fs/writeFileSync cfg-path "{:output-format :json}") + _ (run-cli ["graph" "create" "--repo" repo] data-dir cfg-path) + upsert-tag-result (run-cli ["--repo" repo "upsert" "tag" "--name" tag-name] data-dir cfg-path) + upsert-tag-payload (parse-json-output upsert-tag-result) + upsert-property-result (run-cli ["--repo" repo + "upsert" "property" + "--name" property-name + "--type" "node" + "--cardinality" "many"] + data-dir cfg-path) + upsert-property-payload (parse-json-output upsert-property-result) + update-property-result (run-cli ["--repo" repo + "upsert" "property" + "--name" property-name + "--type" "node" + "--cardinality" "one"] + data-dir cfg-path) + update-property-payload (parse-json-output update-property-result) + property-schema-before-remove (run-query data-dir cfg-path repo + "[:find ?type ?cardinality :in $ ?name :where [?p :block/name ?name] [?p :logseq.property/type ?type] [?p :db/cardinality ?cardinality]]" + (pr-str [property-name-lc])) + remove-tag-result (run-cli ["--repo" repo "remove" "tag" "--name" tag-name] data-dir cfg-path) + remove-tag-payload (parse-json-output remove-tag-result) + remove-property-result (run-cli ["--repo" repo "remove" "property" "--name" property-name] data-dir cfg-path) + remove-property-payload (parse-json-output remove-property-result) + list-tag-result (run-cli ["--repo" repo "list" "tag"] data-dir cfg-path) + list-tag-payload (parse-json-output list-tag-result) + list-property-result (run-cli ["--repo" repo "list" "property"] data-dir cfg-path) + list-property-payload (parse-json-output list-property-result) + tag-names (->> (get-in list-tag-payload [:data :items]) + (map #(or (:block/title %) (:title %) (:name %))) + set) + property-names (->> (get-in list-property-payload [:data :items]) + (map #(or (:block/title %) (:title %) (:name %))) + set) + stop-result (run-cli ["server" "stop" "--repo" repo] data-dir cfg-path) + stop-payload (parse-json-output stop-result)] + (is (= 0 (:exit-code upsert-tag-result))) + (is (= "ok" (:status upsert-tag-payload))) + (is (= 0 (:exit-code upsert-property-result))) + (is (= "ok" (:status upsert-property-payload))) + (is (= 0 (:exit-code update-property-result))) + (is (= "ok" (:status update-property-payload))) + (is (= [["node" "one"]] + (get-in property-schema-before-remove [:data :result]))) + (is (= 0 (:exit-code remove-tag-result))) + (is (= "ok" (:status remove-tag-payload)) + (pr-str remove-tag-payload)) + (is (= 0 (:exit-code remove-property-result))) + (is (= "ok" (:status remove-property-payload)) + (pr-str remove-property-payload)) + (is (not (contains? tag-names tag-name))) + (is (not (contains? property-names property-name))) + (is (= "ok" (:status stop-payload))) + (done)) + (p/catch (fn [e] + (is false (str "unexpected error: " e)) + (done))))))) + (deftest ^:long test-cli-query (async done (let [data-dir (node-helper/create-tmp-dir "db-worker-query")