From 7a9d0181ec9301d568941c9d3cae9b5cae8a2fc0 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Wed, 21 Jan 2026 22:33:16 +0800 Subject: [PATCH] 011-logseq-cli-search-optimization.md --- .../agent-guide/002-logseq-cli-subcommands.md | 4 +- .../004-logseq-cli-verb-subcommands.md | 11 +- .../011-logseq-cli-search-optimization.md | 111 +++++++++ docs/cli/logseq-cli.md | 9 +- src/main/logseq/cli/command/core.cljs | 17 +- src/main/logseq/cli/command/search.cljs | 211 +++++++++++++----- src/main/logseq/cli/command/show.cljs | 38 +++- src/main/logseq/cli/commands.cljs | 2 +- src/main/logseq/cli/format.cljs | 11 +- src/test/logseq/cli/commands_test.cljs | 72 +++++- src/test/logseq/cli/format_test.cljs | 54 +++-- src/test/logseq/cli/integration_test.cljs | 60 ++++- 12 files changed, 487 insertions(+), 113 deletions(-) create mode 100644 docs/agent-guide/011-logseq-cli-search-optimization.md diff --git a/docs/agent-guide/002-logseq-cli-subcommands.md b/docs/agent-guide/002-logseq-cli-subcommands.md index 5824f5c475..836a34ab16 100644 --- a/docs/agent-guide/002-logseq-cli-subcommands.md +++ b/docs/agent-guide/002-logseq-cli-subcommands.md @@ -43,7 +43,7 @@ The CLI will expose these subcommands and shared output controls. | graph info | Graph metadata | human, json, edn | Replaces graph-info | | block add | Add blocks | human, json, edn | Replaces add | | block remove | Remove block or page | human, json, edn | Replaces remove | -| block search | Search blocks | human, json, edn | Replaces search | +| search | Search graph | human, json, edn | Replaces search | | block tree | Show tree | human, json, edn | Replaces tree | The plan assumes a single global output flag that defaults to human, and each subcommand may also accept it. @@ -74,7 +74,7 @@ Each subcommand uses a nested path and its own options. | graph info | none | --repo GRAPH, --output | Shows metadata, defaults to config repo if omitted. | | block add | none | --content TEXT, --blocks EDN, --blocks-file PATH, --page PAGE, --parent UUID, --output | Content source is required, with file and text variants. | | block remove | none | --block UUID, --page PAGE, --output | One of block or page is required. | -| block search | none | --text TEXT, --limit N, --output | Search text is required. | +| search | QUERY | --type page|block|tag|property|all, --tag NAME, --case-sensitive, --sort updated-at|created-at, --order asc|desc, --output | Search text is positional and required. Human output columns: ID (db/id), TITLE. Block reference UUIDs in text are resolved recursively up to 10 levels. | | block tree | none | --block UUID, --page PAGE, --format FORMAT, --output | One of block or page is required, and format controls tree rendering. | ## Plan diff --git a/docs/agent-guide/004-logseq-cli-verb-subcommands.md b/docs/agent-guide/004-logseq-cli-verb-subcommands.md index dd21e48368..e2eb899a03 100644 --- a/docs/agent-guide/004-logseq-cli-verb-subcommands.md +++ b/docs/agent-guide/004-logseq-cli-verb-subcommands.md @@ -103,16 +103,15 @@ List block is removed to avoid overlap with search. ## Search options detail -Search has no subcommands and searches across pages, blocks, tags, and properties by default. +Search has no subcommands and searches across pages, blocks, tags, and properties by default. Human output columns are `ID` (db/id) and `TITLE`. +Search and show outputs resolve block reference UUIDs in text. Nested references are resolved recursively up to 10 levels (e.g., `[[]]` → `[[some text [[]]]]`, then `` is also replaced). | Option | Purpose | Notes | | --- | --- | --- | -| --text QUERY | Search text | Required unless positional args are used. | +| QUERY | Search text | Required positional argument. | | --type page|block|tag|property|all | Restrict types | Default is all. | | --tag NAME | Restrict to a specific tag | Tag is a class page, e.g. Page, Asset, Task. | -| --limit N | Limit results | Apply after merging type results. | | --case-sensitive | Case sensitive search | Default is case-insensitive. | -| --include-content | Search block content, not just title | Requires query expansion. | | --sort updated-at|created-at | Sort results | Default is relevance or stable order. | | --order asc|desc | Sort direction | Defaults to desc for time sorts. | @@ -133,7 +132,7 @@ Show has no subcommands and returns the block tree for a page or block. 1. Review current CLI command parsing and action routing in src/main/logseq/cli/commands.cljs to map block group behavior to verb-first commands. 2. Add failing unit tests in src/test/logseq/cli/commands_test.cljs for verb-first help output and parse behavior for list, add, remove, search, and show. 3. Add failing unit tests that assert list subtype option parsing and validation for list page, list tag, and list property. -4. Add failing unit tests that assert search defaults to all types and respects --type and --include-content options. +4. Add failing unit tests that assert search defaults to all types and respects --type and positional text. 5. Add failing unit tests that assert show accepts --page-name, --uuid, or --id and rejects missing targets. 6. Run bb dev:test -v logseq.cli.commands-test/test-parse-args and confirm failures are about the new verbs and options. 7. Update src/main/logseq/cli/commands.cljs to replace block subcommands with verb-first entries and to add list subcommand group. @@ -142,7 +141,7 @@ Show has no subcommands and returns the block tree for a page or block. 10. Add list option specs in src/main/logseq/cli/commands.cljs and update validation to enforce required args and mutually exclusive flags. 11. Implement list actions in src/main/logseq/cli/commands.cljs that call existing thread-apis for pages, tags, and properties. 12. Implement add page using thread-api/apply-outliner-ops with :create-page in src/main/logseq/cli/commands.cljs. -13. Update search logic in src/main/logseq/cli/commands.cljs to query all resource types and honor --type, --tag, --sort, and --include-content. +13. Update search logic in src/main/logseq/cli/commands.cljs to query all resource types and honor --type, --tag, --sort, and --order. 14. Update show logic in src/main/logseq/cli/commands.cljs to support --id, --uuid, --page-name, and --level. 15. Add failing integration tests in src/test/logseq/cli/integration_test.cljs for list page, list tag, list property, add page, remove page, search all, and show. 16. Run bb dev:test -v logseq.cli.integration-test/test-cli-list-and-search and confirm failures before implementation. diff --git a/docs/agent-guide/011-logseq-cli-search-optimization.md b/docs/agent-guide/011-logseq-cli-search-optimization.md new file mode 100644 index 0000000000..921b9812a3 --- /dev/null +++ b/docs/agent-guide/011-logseq-cli-search-optimization.md @@ -0,0 +1,111 @@ +# Logseq CLI Search Optimization Implementation Plan + +Goal: Simplify the search command arguments and ensure search covers blocks, pages, tags, and properties by default. + +Architecture: The CLI parses args with babashka.cli, builds a search action, and queries db-worker-node through thread-api endpoints for pages, blocks, tags, and properties. +Architecture: The change stays in the CLI layer and relies on existing thread-api methods in db-worker-node for data access. + +Tech Stack: ClojureScript, babashka.cli, Datascript queries, db-worker-node thread-api. + +Related: Relates to docs/agent-guide/007-logseq-cli-thread-api-and-command-split.md. + +## Problem statement + +The current search command requires the --text option or positional args and joins all positional args into the search string. + +The current search documentation and tests are still written around the --text flag and do not describe default type behavior. + +We need to remove the --text option, use the first positional string as the search text, and ensure searches cover block titles, page names, tag names, and property names with --type defaulting to all. + +We also need to remove the --include-content option and any :block/content usage from CLI search because :block/content is no longer present in db-graph. + +We also need to remove the --limit option because it currently only trims output and does not reduce query work. + +## Testing Plan + +I will add unit tests for CLI parsing that assert the search text is taken from the first positional argument and that --type defaults to all when omitted. + +I will add integration tests for the CLI search command that use the positional search text and verify results include at least one matching item from each type when the graph contains data. + +I will add a formatting test that validates the missing search text hint no longer references --text. + +NOTE: I will write *all* tests before I add any implementation behavior. + +## Scope + +This plan updates CLI search option parsing and action building for search types. + +This plan removes --include-content from CLI search options and eliminates :block/content usage from CLI search code and tests. + +This plan updates user-facing docs that describe the search command usage. + +## Non-goals + +This plan does not change db-worker-node thread-api method signatures or introduce new server endpoints. + +This plan does not alter vector search or inference-worker behavior. + +## Expected CLI behavior + +| Scenario | Input | Behavior | +| --- | --- | --- | +| Basic search | logseq-cli search "hello" | Uses "hello" as search text and searches pages, blocks, tags, and properties. | +| Type filter | logseq-cli search "hello" --type page | Searches only pages. | +| Missing text | logseq-cli search | Returns missing-search-text with a hint that positional text is required. | +| Block titles | logseq-cli search "todo" | Matches block titles only, not :block/content. | + +## Implementation Plan + +1. Read @test-driven-development and follow TDD for every behavior change in this plan. +2. Update CLI parsing tests in `src/test/logseq/cli/commands_test.cljs` to use positional search text and verify default types are all when --type is omitted. +3. Run `bb dev:test -v logseq.cli.commands-test` and confirm the new tests fail for the expected reasons. +4. Update integration tests in `src/test/logseq/cli/integration_test.cljs` to call search without --text and to assert results still return ok with data. +5. Run `bb dev:test -v logseq.cli.integration-test` and confirm the new tests fail for the expected reasons. +6. Update formatting expectations in `src/test/logseq/cli/format_test.cljs` if missing-search-text hints change. +7. Run `bb dev:test -v logseq.cli.format-test` and confirm the new tests fail for the expected reasons. +8. Remove the :text, :include-content, and :limit options from search spec in `src/main/logseq/cli/command/search.cljs` and update help text accordingly. +9. Update `src/main/logseq/cli/commands.cljs` to require positional search text and to stop referencing :text in missing-search-text logic. +10. Update `src/main/logseq/cli/command/search.cljs` build-action to use the first positional argument as text and ignore any additional positional args for search text. +11. Remove :block/content references from `src/main/logseq/cli/command/search.cljs` so block searches use block title fields only. +12. Update `src/main/logseq/cli/format.cljs` to remove the hint that references --text. +13. Update docs in `docs/cli/logseq-cli.md`, `docs/agent-guide/004-logseq-cli-verb-subcommands.md`, and `docs/agent-guide/002-logseq-cli-subcommands.md` to remove --text, --include-content, and --limit and document the new positional argument behavior and default --type behavior. +14. Update CLI tests in `src/test/logseq/cli/commands_test.cljs`, `src/test/logseq/cli/integration_test.cljs`, and `src/test/logseq/cli/format_test.cljs` to remove --include-content, --limit, and any :block/content expectations. +15. Run `bb dev:lint-and-test` and confirm all linters and unit tests pass. + +## Edge cases and validation + +Multiple positional arguments should not be concatenated into a single search string unless explicitly required by design. + +Search text containing spaces should still work when the shell passes it as a single quoted argument. + +When --type is provided, only the requested type set should be searched and defaults should not override the filter. + +Search with --tag should continue to filter block searches and should not filter page, tag, or property results unless explicitly required. + +The CLI must not attempt to query :block/content because the attribute is absent in db-graph. + +## Testing Details + +The CLI command tests will assert that a positional search term maps to the action :text and that missing text errors are raised when no positional arguments exist. + +The integration tests will execute the CLI against a sample graph, verify the command exits with ok, and confirm search results are a vector for each type requested. + +The formatting tests will assert the error hint no longer suggests the deprecated --text option. + +The CLI tests will assert that no --include-content or --limit option exists and that searches do not rely on :block/content. + +## Implementation Details + +- Remove :text, :include-content, and :limit from the search option spec and adjust help text generation to avoid advertising those options. +- Update missing-search-text validation to rely only on positional args. +- Use only the first positional argument as the search text to match the new spec. +- Confirm default search types flow through normalize-search-types when --type is absent. +- Remove :block/content usage from query-blocks and any related logic. +- Update docs and examples to show quoted positional search text. +- Ensure error hints reference positional text rather than options. + +## Question + +No open questions. + +--- diff --git a/docs/cli/logseq-cli.md b/docs/cli/logseq-cli.md index 4caa1601ec..32188fae58 100644 --- a/docs/cli/logseq-cli.md +++ b/docs/cli/logseq-cli.md @@ -70,7 +70,7 @@ Inspect and edit commands: - `move --id |--uuid --target-id |--target-uuid |--target-page-name [--pos first-child|last-child|sibling]` - move a block and its children (defaults to first-child) - `remove block --block ` - remove a block and its children - `remove page --page ` - remove a page and its children -- `search --text [--type page|block|tag|property|all] [--include-content] [--limit ]` - search across pages, blocks, tags, and properties +- `search [--type page|block|tag|property|all] [--tag ] [--case-sensitive] [--sort updated-at|created-at] [--order asc|desc]` - search across pages, blocks, tags, and properties (query is positional) - `show --page-name [--format text|json|edn] [--level ]` - show page tree - `show --uuid [--format text|json|edn] [--level ]` - show block tree - `show --id [--format text|json|edn] [--level ]` - show block tree by db/id @@ -87,7 +87,7 @@ Subcommands: move [options] Move block remove block [options] Remove block remove page [options] Remove page - search [options] Search graph + search [options] Search graph show [options] Show tree ``` @@ -97,7 +97,8 @@ Options grouping: Output formats: - Global `--output ` (also accepted per subcommand) - For `graph export`, `--output` refers to the destination file path. Output formatting is controlled via `:output-format` in config or `LOGSEQ_CLI_OUTPUT`. -- Human output is plain text. List/search commands render tables with a final `Count: N` line. For list subcommands, the ID column uses `:db/id` (not UUID). If `:db/ident` exists, an `IDENT` column is included. 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. Search table columns are `ID` and `TITLE`. 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. +- Show and search outputs resolve block reference UUIDs inside text, replacing `[[]]` with the referenced block content. Nested references are resolved recursively up to 10 levels to avoid excessive expansion. For example: `[[]]` → `[[some text [[]]]]` and then `` is also replaced. - `show` human output prints the `:db/id` as the first column followed by a tree: ``` @@ -119,7 +120,7 @@ node ./static/logseq-cli.js graph export --type edn --output /tmp/demo.edn --rep node ./static/logseq-cli.js graph import --type edn --input /tmp/demo.edn --repo demo-import node ./static/logseq-cli.js add block --target-page-name TestPage --content "hello world" node ./static/logseq-cli.js move --uuid --target-page-name TargetPage -node ./static/logseq-cli.js search --text "hello" +node ./static/logseq-cli.js search "hello" node ./static/logseq-cli.js show --page-name TestPage --format json --output json node ./static/logseq-cli.js server list ``` diff --git a/src/main/logseq/cli/command/core.cljs b/src/main/logseq/cli/command/core.cljs index 407f6bf558..58d9d9fa75 100644 --- a/src/main/logseq/cli/command/core.cljs +++ b/src/main/logseq/cli/command/core.cljs @@ -41,13 +41,21 @@ :opts opts :args args})})) +(defn- command-usage + [cmds spec] + (let [base (string/join " " cmds) + positional (when (= cmds ["search"]) "") + has-options? (seq spec)] + (cond-> base + positional (str " " positional) + has-options? (str " [options]")))) + (defn- format-commands [table] (let [rows (->> table (filter (comp seq :cmds)) (map (fn [{:keys [cmds desc spec]}] - (let [command (str (string/join " " cmds) - (when (seq spec) " [options]"))] + (let [command (command-usage cmds spec)] {:command command :desc desc})))) width (apply max 0 (map (comp count :command) rows))] @@ -98,7 +106,7 @@ [{:keys [cmds spec]}] (let [command-spec (apply dissoc spec (keys global-spec*))] (string/join "\n" - [(str "Usage: logseq " (string/join " " cmds) " [options]") + [(str "Usage: logseq " (command-usage cmds spec)) "" "Global options:" (cli/format-opts {:spec global-spec*}) @@ -210,7 +218,6 @@ (graph->repo graph)))) (defn pick-graph - [options command-args config] + [options _command-args config] (or (:repo options) - (first command-args) (:repo config))) diff --git a/src/main/logseq/cli/command/search.cljs b/src/main/logseq/cli/command/search.cljs index 074422324a..c7cff1f65f 100644 --- a/src/main/logseq/cli/command/search.cljs +++ b/src/main/logseq/cli/command/search.cljs @@ -1,21 +1,18 @@ (ns logseq.cli.command.search "Search-related CLI commands." - (:require [clojure.string :as string] + (:require [clojure.set :as set] + [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 search-spec - {:text {:desc "Search text"} - :type {:desc "Search types (page, block, tag, property, all)"} + {:type {:desc "Search types (page, block, tag, property, all)"} :tag {:desc "Restrict to a specific tag"} - :limit {:desc "Limit results" - :coerce :long} :case-sensitive {:desc "Case sensitive search" :coerce :boolean} - :include-content {:desc "Search block content" - :coerce :boolean} :sort {:desc "Sort field (updated-at, created-at)"} :order {:desc "Sort order (asc, desc)"}}) @@ -25,6 +22,9 @@ (def ^:private search-types #{"page" "block" "tag" "property" "all"}) +(def ^:private uuid-ref-pattern #"\[\[([0-9a-fA-F-]{36})\]\]") +(def ^:private uuid-ref-max-depth 10) + (defn invalid-options? [opts] (let [type (:type opts) @@ -49,17 +49,15 @@ {:ok? false :error {:code :missing-repo :message "repo is required for search"}} - (let [text (or (:text options) (string/join " " args))] + (let [text (some-> (first args) string/trim)] (if (seq text) {:ok? true :action {:type :search :repo repo :text text - :search-type (:type options) + :search-type (or (:type options) "all") :tag (:tag options) - :limit (:limit options) :case-sensitive (:case-sensitive options) - :include-content (:include-content options) :sort (:sort options) :order (:order options)}} {:ok? false @@ -86,68 +84,170 @@ [?e :block/uuid ?uuid] [(get-else $ ?e :block/updated-at 0) ?updated] [(get-else $ ?e :block/created-at 0) ?created] - [(clojure.string/includes? (clojure.string/lower-case ?title) ?q)]]) + [(clojure.string/includes? ?name ?q)]]) q* (if case-sensitive? text (string/lower-case text))] (transport/invoke cfg :thread-api/q false [repo [query q*]]))) -#_{:clj-kondo/ignore [:aliased-namespace-symbol]} (defn- query-blocks - [cfg repo text case-sensitive? tag include-content?] - (let [has-tag? (seq tag) - content-attr (if include-content? :block/content :block/title) + [cfg repo text case-sensitive? tag] + (let [q* (if case-sensitive? text (string/lower-case text)) + tag-name (some-> tag string/lower-case) query (cond - (and case-sensitive? has-tag?) - `[:find ?e ?value ?uuid ?updated ?created + (and case-sensitive? (seq tag-name)) + '[:find ?e ?title ?uuid ?updated ?created :in $ ?q ?tag-name :where - [?tag :block/name ?tag-name] - [?e :block/tags ?tag] - [?e ~content-attr ?value] - [(missing? $ ?e :block/name)] + [?e :block/title ?title] + [?e :block/page ?page] [?e :block/uuid ?uuid] [(get-else $ ?e :block/updated-at 0) ?updated] [(get-else $ ?e :block/created-at 0) ?created] - [(clojure.string/includes? ?value ?q)]] + [(clojure.string/includes? ?title ?q)] + [?tag :block/name ?tag-name] + [?e :block/tags ?tag]] case-sensitive? - `[:find ?e ?value ?uuid ?updated ?created + '[:find ?e ?title ?uuid ?updated ?created :in $ ?q :where - [?e ~content-attr ?value] - [(missing? $ ?e :block/name)] + [?e :block/title ?title] + [?e :block/page ?page] [?e :block/uuid ?uuid] [(get-else $ ?e :block/updated-at 0) ?updated] [(get-else $ ?e :block/created-at 0) ?created] - [(clojure.string/includes? ?value ?q)]] + [(clojure.string/includes? ?title ?q)]] - has-tag? - `[:find ?e ?value ?uuid ?updated ?created - :in $ ?q ?tag-name + (seq tag-name) + '[:find ?e ?title ?uuid ?updated ?created + :in $ ?tag-name :where - [?tag :block/name ?tag-name] - [?e :block/tags ?tag] - [?e ~content-attr ?value] - [(missing? $ ?e :block/name)] + [?e :block/title ?title] + [?e :block/page ?page] [?e :block/uuid ?uuid] [(get-else $ ?e :block/updated-at 0) ?updated] [(get-else $ ?e :block/created-at 0) ?created] - [(clojure.string/includes? (clojure.string/lower-case ?value) ?q)]] + [?tag :block/name ?tag-name] + [?e :block/tags ?tag]] :else - `[:find ?e ?value ?uuid ?updated ?created - :in $ ?q + '[:find ?e ?title ?uuid ?updated ?created :where - [?e ~content-attr ?value] - [(missing? $ ?e :block/name)] + [?e :block/title ?title] + [?e :block/page ?page] [?e :block/uuid ?uuid] [(get-else $ ?e :block/updated-at 0) ?updated] - [(get-else $ ?e :block/created-at 0) ?created] - [(clojure.string/includes? (clojure.string/lower-case ?value) ?q)]]) - q* (if case-sensitive? text (string/lower-case text)) - tag-name (some-> tag string/lower-case)] - (if has-tag? - (transport/invoke cfg :thread-api/q false [repo [query q* tag-name]]) - (transport/invoke cfg :thread-api/q false [repo [query q*]])))) + [(get-else $ ?e :block/created-at 0) ?created]]) + query-args (cond + (and case-sensitive? (seq tag-name)) + [repo [query q* tag-name]] + + case-sensitive? + [repo [query q*]] + + (seq tag-name) + [repo [query tag-name]] + + :else + [repo [query]]) + matches-text? (fn [title] + (when (string? title) + (if case-sensitive? + (string/includes? title q*) + (string/includes? (string/lower-case title) q*))))] + (-> (p/let [rows (transport/invoke cfg :thread-api/q false query-args)] + (->> (or rows []) + (filter (fn [[_ title _ _ _]] + (matches-text? title))) + (mapv (fn [[id title uuid updated created]] + {:type "block" + :db/id id + :content title + :uuid (str uuid) + :updated-at updated + :created-at created})))) + (p/catch (fn [_] + []))))) + +(defn- replace-uuid-refs-once + [value uuid->label] + (if (and (string? value) (seq uuid->label)) + (string/replace value uuid-ref-pattern + (fn [[_ id]] + (if-let [label (get uuid->label (string/lower-case id))] + (str "[[" label "]]") + (str "[[" id "]]")))) + value)) + +(defn- replace-uuid-refs + [value uuid->label] + (loop [current value + remaining uuid-ref-max-depth] + (if (or (not (string? current)) (zero? remaining) (empty? uuid->label)) + current + (let [next (replace-uuid-refs-once current uuid->label)] + (if (= next current) + current + (recur next (dec remaining))))))) + +(defn- extract-uuid-refs + [value] + (->> (re-seq uuid-ref-pattern (or value "")) + (map second) + (filter common-util/uuid-string?) + (map string/lower-case) + distinct)) + +(defn- collect-uuid-refs + [results] + (->> results + (mapcat (fn [item] (keep item [:title :content]))) + (remove string/blank?) + (mapcat extract-uuid-refs) + distinct + vec)) + +(defn- fetch-uuid-labels + [config repo uuid-strings] + (if (seq uuid-strings) + (p/let [blocks (p/all (map (fn [uuid-str] + (transport/invoke config :thread-api/pull false + [repo [:block/uuid :block/title :block/name] + [:block/uuid (uuid uuid-str)]])) + uuid-strings))] + (->> blocks + (remove nil?) + (map (fn [block] + (let [uuid-str (some-> (:block/uuid block) str)] + [(string/lower-case uuid-str) + (or (:block/title block) (:block/name block) uuid-str)]))) + (into {}))) + (p/resolved {}))) + +(defn- fetch-uuid-labels-recursive + [config repo uuid-strings] + (p/loop [pending (set (map string/lower-case uuid-strings)) + seen #{} + labels {} + remaining uuid-ref-max-depth] + (if (or (empty? pending) (zero? remaining)) + labels + (p/let [fetched (fetch-uuid-labels config repo pending) + next-labels (merge labels fetched) + next-seen (into seen (keys fetched)) + nested-refs (->> (vals fetched) + (mapcat extract-uuid-refs) + (remove next-seen) + set) + next-pending (set/difference nested-refs next-seen)] + (p/recur next-pending next-seen next-labels (dec remaining)))))) + +(defn- resolve-uuid-refs-in-results + [results uuid->label] + (mapv (fn [item] + (cond-> item + (:title item) (update :title replace-uuid-refs uuid->label) + (:content item) (update :content replace-uuid-refs uuid->label))) + (or results []))) (defn- normalize-search-types [type] @@ -183,17 +283,8 @@ :updated-at updated :created-at created}) rows))) - include-content? (boolean (:include-content action)) block-results (when (some #{:block} types) - (p/let [rows (query-blocks cfg (:repo action) text case-sensitive? tag include-content?)] - (mapv (fn [[id content uuid updated created]] - {:type "block" - :db/id id - :content content - :uuid (str uuid) - :updated-at updated - :created-at created}) - rows))) + (query-blocks cfg (:repo action) text case-sensitive? tag)) tag-results (when (some #{:tag} types) (p/let [items (transport/invoke cfg :thread-api/api-list-tags false [(:repo action) {:expand true :include-built-in true}]) @@ -206,6 +297,7 @@ (string/includes? (string/lower-case title) q*))))) (mapv (fn [item] {:type "tag" + :db/id (:db/id item) :title (:block/title item) :uuid (:block/uuid item)}))))) property-results (when (some #{:property} types) @@ -220,6 +312,7 @@ (string/includes? (string/lower-case title) q*))))) (mapv (fn [item] {:type "property" + :db/id (:db/id item) :title (:block/title item) :uuid (:block/uuid item)}))))) results (->> (concat (or page-results []) @@ -235,6 +328,8 @@ (cond-> (= order "desc") reverse) vec)) results) - limited (if (some? (:limit action)) (vec (take (:limit action) sorted)) sorted)] + uuid-refs (collect-uuid-refs sorted) + uuid->label (fetch-uuid-labels-recursive cfg (:repo action) uuid-refs) + resolved (resolve-uuid-refs-in-results sorted uuid->label)] {:status :ok - :data {:results limited}}))) + :data {:results resolved}}))) diff --git a/src/main/logseq/cli/command/show.cljs b/src/main/logseq/cli/command/show.cljs index 430280dc83..91e47dcfb4 100644 --- a/src/main/logseq/cli/command/show.cljs +++ b/src/main/logseq/cli/command/show.cljs @@ -61,6 +61,7 @@ (declare tree->text) (def ^:private uuid-ref-pattern #"\[\[([0-9a-fA-F-]{36})\]\]") +(def ^:private uuid-ref-max-depth 10) (defn- tag-label [tag] @@ -68,7 +69,7 @@ (:block/name tag) (some-> (:block/uuid tag) str))) -(defn- replace-uuid-refs +(defn- replace-uuid-refs-once [value uuid->label] (if (and (string? value) (seq uuid->label)) (string/replace value uuid-ref-pattern @@ -78,6 +79,17 @@ (str "[[" id "]]")))) value)) +(defn- replace-uuid-refs + [value uuid->label] + (loop [current value + remaining uuid-ref-max-depth] + (if (or (not (string? current)) (zero? remaining) (empty? uuid->label)) + current + (let [next (replace-uuid-refs-once current uuid->label)] + (if (= next current) + current + (recur next (dec remaining))))))) + (defn- tags->suffix [tags] (let [labels (->> tags @@ -122,6 +134,29 @@ tags-suffix tags-suffix :else base))) +(defn- resolve-uuid-refs-in-node + [node uuid->label] + (cond-> node + (:block/title node) (update :block/title replace-uuid-refs uuid->label) + (:block/name node) (update :block/name replace-uuid-refs uuid->label) + (:block/children node) (update :block/children (fn [children] + (mapv #(resolve-uuid-refs-in-node % uuid->label) children))) + (:block/page node) (update :block/page (fn [page] + (if (map? page) + (resolve-uuid-refs-in-node page uuid->label) + page))) + (:block/tags node) (update :block/tags (fn [tags] + (mapv #(resolve-uuid-refs-in-node % uuid->label) tags))))) + +(defn- resolve-uuid-refs-in-tree-data + [{:keys [linked-references] :as tree-data} uuid->label] + (let [resolve-node #(resolve-uuid-refs-in-node % uuid->label)] + (cond-> (update tree-data :root resolve-node) + (seq (:blocks linked-references)) + (update :linked-references + (fn [refs] + (update refs :blocks #(mapv resolve-node %))))))) + (defn- page-label [block] (let [page (:block/page block)] @@ -429,6 +464,7 @@ tree-data (assoc tree-data :linked-references linked-refs :uuid->label uuid->label) + tree-data (resolve-uuid-refs-in-tree-data tree-data uuid->label) format (:format action)] (case format "edn" diff --git a/src/main/logseq/cli/commands.cljs b/src/main/logseq/cli/commands.cljs index 1da9b0e9ff..ca0598bb73 100644 --- a/src/main/logseq/cli/commands.cljs +++ b/src/main/logseq/cli/commands.cljs @@ -164,7 +164,7 @@ (and (= command :show) (> (count show-targets) 1)) (command-core/invalid-options-result summary "only one of --id, --uuid, or --page-name is allowed") - (and (= command :search) (not (or (seq (:text opts)) has-args?))) + (and (= command :search) (not has-args?)) (missing-search-result summary) (and (#{:list-page :list-tag :list-property} command) diff --git a/src/main/logseq/cli/format.cljs b/src/main/logseq/cli/format.cljs index 8c8869af92..13b1cddc99 100644 --- a/src/main/logseq/cli/format.cljs +++ b/src/main/logseq/cli/format.cljs @@ -71,7 +71,7 @@ :missing-graph "Use --repo " :missing-repo "Use --repo " :missing-content "Use --content or pass content as args" - :missing-search-text "Provide search text or --text" + :missing-search-text "Provide search text as a positional argument" nil)) (defn- format-error @@ -166,13 +166,10 @@ (defn- format-search-results [results] (format-counted-table - ["TYPE" "TITLE/CONTENT" "UUID" "UPDATED-AT" "CREATED-AT"] + ["ID" "TITLE"] (mapv (fn [item] - [(:type item) - (or (:title item) (:content item)) - (:uuid item) - (:updated-at item) - (:created-at item)]) + [(:db/id item) + (or (:title item) (:content item))]) (or results [])))) (defn- format-graph-info diff --git a/src/test/logseq/cli/commands_test.cljs b/src/test/logseq/cli/commands_test.cljs index 8c983635b8..19a3005944 100644 --- a/src/test/logseq/cli/commands_test.cljs +++ b/src/test/logseq/cli/commands_test.cljs @@ -273,15 +273,44 @@ (tree->text tree-data)))))) (deftest test-tree->text-replaces-uuid-refs - (testing "show tree text replaces inline [[uuid]] with referenced block content" + (testing "show tree text replaces inline [[uuid]] with referenced block content recursively" (let [tree->text #'show-command/tree->text uuid "11111111-1111-1111-1111-111111111111" + nested "22222222-2222-2222-2222-222222222222" tree-data {:root {:db/id 1 :block/title (str "See [[" uuid "]]")} - :uuid->label {(string/lower-case uuid) "Target block"}}] - (is (= (str "1 See [[Target block]]") + :uuid->label {(string/lower-case uuid) (str "Target [[" nested "]]") + (string/lower-case nested) "Inner"}}] + (is (= (str "1 See [[Target [[Inner]]]]") (tree->text tree-data)))))) +(deftest test-tree->text-uuid-ref-recursion-limit + (testing "show tree text limits uuid ref replacement depth" + (let [tree->text #'show-command/tree->text + uuids ["00000000-0000-0000-0000-000000000001" + "00000000-0000-0000-0000-000000000002" + "00000000-0000-0000-0000-000000000003" + "00000000-0000-0000-0000-000000000004" + "00000000-0000-0000-0000-000000000005" + "00000000-0000-0000-0000-000000000006" + "00000000-0000-0000-0000-000000000007" + "00000000-0000-0000-0000-000000000008" + "00000000-0000-0000-0000-000000000009" + "00000000-0000-0000-0000-000000000010" + "00000000-0000-0000-0000-000000000011"] + uuid->label (into {} + (map-indexed (fn [idx id] + (let [label (if (< idx 10) + (str "L" (inc idx) " [[" (nth uuids (inc idx)) "]]") + (str "L" (inc idx)))] + [(string/lower-case id) label])) + uuids)) + tree-data {:root {:db/id 1 + :block/title (str "Root [[" (first uuids) "]]")} + :uuid->label uuid->label} + result (tree->text tree-data)] + (is (string/includes? result (str "[[" (nth uuids 10) "]]")))))) + (deftest test-list-subcommand-parse (testing "list page parses" (let [result (commands/parse-args ["list" "page" @@ -437,10 +466,10 @@ (is (= :missing-search-text (get-in result [:error :code]))))) (testing "search parses with text" - (let [result (commands/parse-args ["search" "--text" "hello"])] + (let [result (commands/parse-args ["search" "hello"])] (is (true? (:ok? result))) (is (= :search (:command result))) - (is (= "hello" (get-in result [:options :text]))))) + (is (= ["hello"] (:args result))))) (testing "show requires target" (let [result (commands/parse-args ["show"])] @@ -454,6 +483,11 @@ (is (= "Home" (get-in result [:options :page-name])))))) (deftest test-verb-subcommand-parse-graph-import-export + (testing "graph create requires --repo even with positional args" + (let [result (commands/parse-args ["graph" "create" "demo"])] + (is (false? (:ok? result))) + (is (= :missing-graph (get-in result [:error :code]))))) + (testing "graph export parses with type and output" (let [result (commands/parse-args ["graph" "export" "--type" "edn" @@ -511,8 +545,16 @@ (is (false? (:ok? result))) (is (= :invalid-options (get-in result [:error :code])))))) + (testing "search rejects deprecated flags" + (doseq [args [["search" "--limit" "10" "hello"] + ["search" "--include-content" "hello"] + ["search" "--text" "hello"]]] + (let [result (commands/parse-args args)] + (is (false? (:ok? result))) + (is (= :invalid-options (get-in result [:error :code])))))) + (testing "verb subcommands accept output option" - (let [result (commands/parse-args ["search" "--text" "hello" "--output" "json"])] + (let [result (commands/parse-args ["search" "--output" "json" "hello"])] (is (true? (:ok? result))) (is (= "json" (get-in result [:options :output])))))) @@ -613,6 +655,24 @@ (is (false? (:ok? result))) (is (= :missing-search-text (get-in result [:error :code]))))) + (testing "search defaults to all types" + (let [parsed {:ok? true :command :search :options {} :args ["hello"]} + result (commands/build-action parsed {:repo "demo"})] + (is (true? (:ok? result))) + (is (= "all" (get-in result [:action :search-type]))))) + + (testing "search uses config repo and ignores positional text for repo" + (let [parsed {:ok? true :command :search :options {} :args ["hello"]} + result (commands/build-action parsed {:repo "demo"})] + (is (true? (:ok? result))) + (is (= "logseq_db_demo" (get-in result [:action :repo]))))) + + (testing "search uses first positional argument" + (let [parsed {:ok? true :command :search :options {} :args ["hello" "world"]} + result (commands/build-action parsed {:repo "demo"})] + (is (true? (:ok? result))) + (is (= "hello" (get-in result [:action :text]))))) + (testing "show requires target" (let [parsed {:ok? true :command :show :options {}} result (commands/build-action parsed {:repo "demo"})] diff --git a/src/test/logseq/cli/format_test.cljs b/src/test/logseq/cli/format_test.cljs index a0c13c830a..49e1ba19b1 100644 --- a/src/test/logseq/cli/format_test.cljs +++ b/src/test/logseq/cli/format_test.cljs @@ -164,27 +164,31 @@ (let [result (format/format-result {:status :ok :command :search :data {:results [{:type "page" - :title "Alpha" - :uuid "u1" - :updated-at 3 - :created-at 1} - {:type "block" - :content "Note" - :uuid "u2" - :updated-at 4 - :created-at 2} - {:type "tag" - :title "Taggy" - :uuid "u3"} - {:type "property" - :title "Prop" - :uuid "u4"}]}} + :db/id 101 + :title "Alpha" + :uuid "u1" + :updated-at 3 + :created-at 1} + {:type "block" + :db/id 102 + :content "Note" + :uuid "u2" + :updated-at 4 + :created-at 2} + {:type "tag" + :db/id 103 + :title "Taggy" + :uuid "u3"} + {:type "property" + :db/id 104 + :title "Prop" + :uuid "u4"}]}} {:output-format nil})] - (is (= (str "TYPE TITLE/CONTENT UUID UPDATED-AT CREATED-AT\n" - "page Alpha u1 3 1\n" - "block Note u2 4 2\n" - "tag Taggy u3 - -\n" - "property Prop u4 - -\n" + (is (= (str "ID TITLE\n" + "101 Alpha\n" + "102 Note\n" + "103 Taggy\n" + "104 Prop\n" "Count: 4") result)))) @@ -204,4 +208,14 @@ {:output-format nil})] (is (= (str "Error (missing-graph): graph name is required\n" "Hint: Use --repo ") + result)))) + + (testing "missing search text hints use positional argument" + (let [result (format/format-result {:status :error + :command :search + :error {:code :missing-search-text + :message "search text is required"}} + {:output-format nil})] + (is (= (str "Error (missing-search-text): search text is required\n" + "Hint: Provide search text as a positional argument") result))))) diff --git a/src/test/logseq/cli/integration_test.cljs b/src/test/logseq/cli/integration_test.cljs index 600ef15eee..9bfbe4cf97 100644 --- a/src/test/logseq/cli/integration_test.cljs +++ b/src/test/logseq/cli/integration_test.cljs @@ -34,6 +34,10 @@ [node] (or (:block/title node) (:title node))) +(defn- node-uuid + [node] + (or (:block/uuid node) (:uuid node))) + (defn- node-children [node] (or (:block/children node) (:children node))) @@ -45,6 +49,7 @@ node (some #(find-block-by-title % title) (node-children node))))) + (deftest test-cli-graph-list (async done (let [data-dir (node-helper/create-tmp-dir "db-worker")] @@ -96,9 +101,10 @@ list-tag-payload (parse-json-output list-tag-result) list-property-result (run-cli ["--repo" "content-graph" "list" "property"] data-dir cfg-path) list-property-payload (parse-json-output list-property-result) - add-block-result (run-cli ["--repo" "content-graph" "add" "block" "--target-page-name" "TestPage" "--content" "hello world"] data-dir cfg-path) - _ (parse-json-output add-block-result) - search-result (run-cli ["--repo" "content-graph" "search" "--text" "hello world"] data-dir cfg-path) + add-block-result (run-cli ["--repo" "content-graph" "add" "block" "--target-page-name" "TestPage" "--content" "Test block"] data-dir cfg-path) + add-block-payload (parse-json-output add-block-result) + _ (p/delay 100) + search-result (run-cli ["--repo" "content-graph" "search" "t"] data-dir cfg-path) search-payload (parse-json-output search-result) show-result (run-cli ["--repo" "content-graph" "show" "--page-name" "TestPage" "--format" "json"] data-dir cfg-path) show-payload (parse-json-output show-result) @@ -108,6 +114,7 @@ stop-payload (parse-json-output stop-result)] (is (= 0 (:exit-code add-page-result))) (is (= "ok" (:status add-page-payload))) + (is (= "ok" (:status add-block-payload))) (is (= "ok" (:status list-page-payload))) (is (vector? (get-in list-page-payload [:data :items]))) (is (= "ok" (:status list-tag-payload))) @@ -116,6 +123,11 @@ (is (vector? (get-in list-property-payload [:data :items]))) (is (= "ok" (:status search-payload))) (is (vector? (get-in search-payload [:data :results]))) + (let [types (set (map :type (get-in search-payload [:data :results])))] + (is (contains? types "page")) + (is (contains? types "block")) + (is (contains? types "tag")) + (is (contains? types "property"))) (is (= "ok" (:status show-payload))) (is (contains? (get-in show-payload [:data :root]) :uuid)) (is (= "ok" (:status remove-page-payload))) @@ -125,6 +137,48 @@ (is false (str "unexpected error: " e)) (done))))))) +(deftest test-cli-show-search-resolve-nested-uuid-refs + (async done + (let [data-dir (node-helper/create-tmp-dir "db-worker-nested-refs")] + (-> (p/let [cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn") + _ (fs/writeFileSync cfg-path "{:output-format :json}") + _ (run-cli ["graph" "create" "--repo" "nested-refs"] data-dir cfg-path) + _ (run-cli ["--repo" "nested-refs" "add" "page" "--page" "NestedPage"] data-dir cfg-path) + _ (run-cli ["--repo" "nested-refs" "add" "block" "--target-page-name" "NestedPage" "--content" "Inner"] data-dir cfg-path) + show-inner (run-cli ["--repo" "nested-refs" "show" "--page-name" "NestedPage" "--format" "json"] data-dir cfg-path) + show-inner-payload (parse-json-output show-inner) + inner-node (find-block-by-title (get-in show-inner-payload [:data :root]) "Inner") + inner-uuid (node-uuid inner-node) + _ (run-cli ["--repo" "nested-refs" "add" "block" "--target-page-name" "NestedPage" + "--content" (str "See [[" inner-uuid "]]")] data-dir cfg-path) + show-middle (run-cli ["--repo" "nested-refs" "show" "--page-name" "NestedPage" "--format" "json"] data-dir cfg-path) + show-middle-payload (parse-json-output show-middle) + middle-node (find-block-by-title (get-in show-middle-payload [:data :root]) "See [[Inner]]") + middle-uuid (node-uuid middle-node) + _ (run-cli ["--repo" "nested-refs" "add" "block" "--target-page-name" "NestedPage" + "--content" (str "Outer [[" middle-uuid "]]")] data-dir cfg-path) + show-outer (run-cli ["--repo" "nested-refs" "show" "--page-name" "NestedPage" "--format" "json"] data-dir cfg-path) + show-outer-payload (parse-json-output show-outer) + outer-node (find-block-by-title (get-in show-outer-payload [:data :root]) "Outer [[See [[Inner]]]]") + search-result (run-cli ["--repo" "nested-refs" "search" "Outer"] data-dir cfg-path) + search-payload (parse-json-output search-result) + search-item (some (fn [item] + (when (and (string? (:content item)) + (string/includes? (:content item) "Outer")) + item)) + (get-in search-payload [:data :results])) + stop-result (run-cli ["server" "stop" "--repo" "nested-refs"] data-dir cfg-path) + stop-payload (parse-json-output stop-result)] + (is (some? inner-uuid)) + (is (some? middle-uuid)) + (is (some? outer-node)) + (is (= "Outer [[See [[Inner]]]]" (:content search-item))) + (is (= "ok" (:status stop-payload))) + (done)) + (p/catch (fn [e] + (is false (str "unexpected error: " e)) + (done))))))) + (deftest test-cli-show-linked-references-json (async done (let [data-dir (node-helper/create-tmp-dir "db-worker-linked-refs")]