diff --git a/deps.edn b/deps.edn index f23263ae59..f329c2c188 100644 --- a/deps.edn +++ b/deps.edn @@ -23,6 +23,7 @@ cljs-http/cljs-http {:mvn/version "0.1.49"} org.babashka/sci {:mvn/version "0.12.51"} org.clj-commons/hickory {:mvn/version "0.7.7"} + org.clj-commons/humanize {:mvn/version "1.2"} org.babashka/cli {:mvn/version "0.8.67"} hiccups/hiccups {:mvn/version "0.3.0"} tongue/tongue {:mvn/version "0.4.4"} diff --git a/docs/agent-guide/086-logseq-cli-humanize-output.md b/docs/agent-guide/086-logseq-cli-humanize-output.md new file mode 100644 index 0000000000..bdd4a283c2 --- /dev/null +++ b/docs/agent-guide/086-logseq-cli-humanize-output.md @@ -0,0 +1,209 @@ +# 086 — Introduce `org.clj-commons/humanize` for logseq-cli human output + +## Goal + +Adopt `org.clj-commons/humanize` in `logseq-cli` to improve readability and consistency of **human-mode** output while keeping `json` and `edn` output stable. + +This plan is based on the current implementation of: + +- CLI output layer: `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/format.cljs` +- CLI command message producers: `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/*.cljs` +- db-worker-node sync progress producers used by CLI: `/Users/rcmerci/gh-repos/logseq/src/main/frontend/worker/sync/download.cljs` and `/Users/rcmerci/gh-repos/logseq/src/main/frontend/worker/sync/upload.cljs` + +--- + +## Scope and constraints + +### In scope + +1. Add `org.clj-commons/humanize` dependency for CLI/runtime code paths. +2. Replace ad-hoc human text formatting in CLI human output with library-backed helpers where it improves readability. +3. Audit all practical human-output points that can benefit from this library and prioritize them. +4. Keep output changes focused on `--output human` behavior. + +### Out of scope + +1. Changing `json` or `edn` payload schemas. +2. Any `db-worker-node` code change (including sync progress message producers and thread-api payload changes). +3. Rewriting CLI table layout/alignment logic (`string-width`, truncation, padding). +4. Internationalization (the library is English-first and current CLI output is also English). + +### Locked decisions + +1. Keep relative time in short style (compatible with current `10s ago` / `1m ago` style). +2. `list asset` `SIZE` displays human-readable size only (no extra raw-byte suffix). +3. Apply grouped-number formatting consistently for `Count:` values. +4. Formatter-only scope: do not modify any `db-worker-node` code. + +--- + +## Why `org.clj-commons/humanize` + +Useful functions for current CLI needs: + +- `clj-commons.humanize/intcomma` for grouped numbers (`12,345`). +- `clj-commons.humanize/filesize` for byte formatting (`2.0KiB`, `3.0MB`). +- `clj-commons.humanize/relative-datetime` for relative time strings. +- `clj-commons.humanize.inflect/pluralize-noun` for singular/plural grammar. +- `clj-commons.humanize/oxford` for readable list joining in error/help text. + +--- + +## Current baseline and optimization inventory + +## A) Central formatter (`format.cljs`) — highest impact + +| Area | Current behavior | Candidate improvement with `humanize` | Priority | +|---|---|---|---| +| `human-ago` for `UPDATED-AT`/`CREATED-AT` columns and graph metadata | Custom `s/m/h/d/mo/y ago` calculation with fixed month/year heuristics | Replace with `relative-datetime` brief mode via a wrapper to keep compact style | P0 | +| `format-counted-table` (`Count: N`) used by list/search/query/server/graph tables | Raw integer | Use `intcomma` consistently for all `Count:` values | P0 | +| `list asset` `SIZE` column | Raw bytes integer | Show human-readable size only (`filesize`, e.g. `2.0KiB`) | P0 | +| `format-sync-status` pending counters and tx values | Raw numbers | `intcomma` for counters/tx ids | P1 | +| `format-server-cleanup` summary counters | Raw numbers | `intcomma` + optional noun pluralization cleanup | P1 | +| `format-upsert-block` change counts and `format-remove-block` multi-id count | Raw counts | `intcomma` for counts | P1 | +| Graph-list legacy warning line (`Warning: N legacy graph directories detected.`) | Raw count with hardcoded noun | `intcomma` + `pluralize-noun` | P1 | + +## B) Command-level message producers (`command/*.cljs`) + +| File | Current behavior | Candidate improvement | Priority | +|---|---|---|---| +| `command/doctor.cljs` (`check-running-servers`, `check-server-revision-mismatch`) | Manual pluralization (`server`/`servers`, `uses`/`use`) | Use `pluralize-noun` (and centralized verb helper if needed) | P0 | +| `command/graph.cljs` (`format-validation-errors`) | Manual `entity/entities` + raw count | `intcomma` + `pluralize-noun` | P0 | +| `command/example.cljs` (`Found N examples ...`) | Raw count | `intcomma` + `pluralize-noun` | P1 | +| `command/show.cljs` (`Linked References (N)`, `Referenced Entities (N)`) | Raw count | `intcomma` for large refs count | P1 | +| `command/sync.cljs` (`missing required sync config ...`) | Comma-joined key list | Optionally use `oxford` for clearer list text | P2 | + +## C) Audited but excluded by current scope decision + +| File | Current behavior | Candidate improvement | Decision | +|---|---|---|---| +| `frontend/worker/sync/download.cljs` (`Importing data X/Y`) | Raw counters in message string | Could use `intcomma` for `X` and `Y` | Excluded (no db-worker changes) | +| `frontend/worker/sync/upload.cljs` (`Uploading X/Y`) | Raw counters in message string | Could use `intcomma` for `X` and `Y` | Excluded (no db-worker changes) | + +## D) Candidate but likely keep as-is for now + +| File | Current behavior | Decision | +|---|---|---| +| `src/main/logseq/cli/profile.cljs` | Technical `Nms` tree output for profiling | Keep as technical output; do not humanize in phase 1 | + +--- + +## Proposed design + +### 1) Add a CLI-local adapter namespace + +Create a small wrapper namespace (e.g. `src/main/logseq/cli/humanize.cljs`) that centralizes: + +- `format-count` (uses `intcomma`) +- `pluralize` (uses `pluralize-noun`) +- `format-filesize` (uses `filesize`, configurable binary/decimal) +- `relative-ago` (uses `relative-datetime` with fixed CLI options) +- Optional list helper (`oxford`) + +This avoids scattering direct library calls and keeps style decisions local to CLI. + +### 2) Preserve machine-output invariants + +All changes must keep: + +- `format-result` behavior for `:json` and `:edn` +- existing keys in command `:data` and `:error` +- transport payload compatibility + +### 3) Strict boundary: CLI formatter only + +- Do not modify any `db-worker-node` files. +- Do not change sync event payload structure. +- Limit all behavior changes to CLI human-output formatting paths. + +--- + +## Implementation plan + +### Phase 1 — dependency + adapter (foundation) + +1. Add dependency in root project deps for CLI compile/runtime usage: + - `/Users/rcmerci/gh-repos/logseq/deps.edn` +2. Add wrapper namespace: + - `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/humanize.cljs` +3. Add unit tests for wrapper behavior (counts, pluralization, relative time, filesize). + +### Phase 2 — migrate high-impact formatter paths + +1. Replace `human-ago` implementation in `format.cljs` with wrapper call. +2. Update `format-counted-table` count rendering with grouped numbers. +3. Update list-asset size rendering to human-readable filesize. +4. Update graph-list/server-cleanup/sync-status numeric display paths. + +Primary file: +- `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/format.cljs` + +### Phase 3 — migrate command message producers + +1. `command/doctor.cljs`: pluralization cleanup. +2. `command/graph.cljs`: validation summary noun/count formatting. +3. `command/example.cljs`: example count formatting. +4. `command/show.cljs`: referenced count formatting. + +Primary files: +- `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/doctor.cljs` +- `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/graph.cljs` +- `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/example.cljs` +- `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/show.cljs` + +### Phase 4 — tests and docs + +1. Update/extend formatter tests for expected human-output changes: + - `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/format_test.cljs` +2. Update command tests where message text is asserted: + - `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/command/doctor_test.cljs` + - `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/command/graph_test.cljs` + - `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/command/show_test.cljs` + - `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/command/example_test.cljs` +3. Update CLI documentation examples if human-mode samples are shown: + - `/Users/rcmerci/gh-repos/logseq/docs/cli/logseq-cli.md` + +--- + +## Verification plan + +Run targeted tests first: + +```bash +bb dev:test -v logseq.cli.format-test +bb dev:test -v logseq.cli.command.doctor-test +bb dev:test -v logseq.cli.command.graph-test +bb dev:test -v logseq.cli.command.show-test +bb dev:test -v logseq.cli.command.example-test +``` + +Then broader checks: + +```bash +bb dev:lint-and-test +bb -f cli-e2e/bb.edn test --skip-build +``` + +--- + +## Acceptance criteria + +1. `org.clj-commons/humanize` is integrated and used by CLI human-output formatting paths. +2. High-impact areas (relative time, `Count:` rendering with grouped numbers, asset filesize, pluralization in doctor/graph) are migrated. +3. Relative time remains in compact short style compatible with current CLI behavior. +4. `json` and `edn` outputs remain schema-compatible. +5. No `db-worker-node` code changes are introduced. +6. Existing CLI tests are updated and passing. + +--- + +## Risks and mitigations + +- **Risk: output churn breaks exact-string tests.** + - Mitigation: migrate in phases and update tests in the same commit; avoid changing machine outputs. + +- **Risk: bundle-size increase from adding library + transitive time utilities.** + - Mitigation: route usage through wrapper, prefer only needed functions, and measure `static/logseq-cli.js` size delta. + +- **Risk: semantic drift in relative-time wording.** + - Mitigation: pin wrapper options to compact short style and keep compatibility snapshots in `format_test.cljs`. diff --git a/src/main/logseq/cli/command/doctor.cljs b/src/main/logseq/cli/command/doctor.cljs index fd5c987de7..09e2fa4e3f 100644 --- a/src/main/logseq/cli/command/doctor.cljs +++ b/src/main/logseq/cli/command/doctor.cljs @@ -4,6 +4,7 @@ [clojure.string :as string] [logseq.cli.command.core :as core] [logseq.cli.data-dir :as data-dir] + [logseq.cli.humanize :as cli-humanize] [logseq.cli.server :as cli-server] [logseq.cli.version :as version] [promesa.core :as p])) @@ -108,9 +109,7 @@ :status :warning :code :doctor-server-not-ready :servers starting - :message (str (count starting) - " server" - (when (> (count starting) 1) "s") + :message (str (cli-humanize/format-count-with-noun (count starting) "server") " still starting: " (string/join ", " (map :repo starting)))}} {:ok? true @@ -147,9 +146,7 @@ :code :doctor-server-revision-mismatch :cli-revision cli-revision :servers mismatch-servers - :message (str mismatch-count - " server" - (when (> mismatch-count 1) "s") + :message (str (cli-humanize/format-count-with-noun mismatch-count "server") " " (if (= 1 mismatch-count) "uses" "use") " a different revision than this CLI")}} diff --git a/src/main/logseq/cli/command/example.cljs b/src/main/logseq/cli/command/example.cljs index 4d3f19ceb4..f0a5ceb90d 100644 --- a/src/main/logseq/cli/command/example.cljs +++ b/src/main/logseq/cli/command/example.cljs @@ -2,6 +2,7 @@ "Example command generation and execution." (:require [clojure.string :as string] [logseq.cli.command.core :as core] + [logseq.cli.humanize :as cli-humanize] [promesa.core :as p])) (def ^:private phase1-groups @@ -122,8 +123,10 @@ :matched-commands matched-commands :examples examples :message (str "Found " - (count examples) - " examples for selector " + (cli-humanize/format-count (count examples)) + " " + (cli-humanize/pluralize-noun (count examples) "example") + " for selector " selector)}}))) (defn execute-example diff --git a/src/main/logseq/cli/command/graph.cljs b/src/main/logseq/cli/command/graph.cljs index f1cf5ac93b..0cf1293ad0 100644 --- a/src/main/logseq/cli/command/graph.cljs +++ b/src/main/logseq/cli/command/graph.cljs @@ -7,6 +7,7 @@ [logseq.cli.command.core :as core] [logseq.cli.common :as cli-common] [logseq.cli.config :as cli-config] + [logseq.cli.humanize :as cli-humanize] [logseq.cli.server :as cli-server] [logseq.cli.transport :as transport] [logseq.common.graph :as common-graph] @@ -421,10 +422,13 @@ (defn- format-validation-errors [errors] - (str "Graph invalid. Found " (count errors) - (if (= 1 (count errors)) " entity" " entities") - " with errors:\n" - (with-out-str (pprint/pprint errors)))) + (let [error-count (count errors)] + (str "Graph invalid. Found " + (cli-humanize/format-count error-count) + " " + (cli-humanize/pluralize-noun error-count "entity") + " with errors:\n" + (with-out-str (pprint/pprint errors))))) (defn- graph-validate-result [result] diff --git a/src/main/logseq/cli/command/show.cljs b/src/main/logseq/cli/command/show.cljs index a1549e53e6..96e4c44c1e 100644 --- a/src/main/logseq/cli/command/show.cljs +++ b/src/main/logseq/cli/command/show.cljs @@ -6,6 +6,7 @@ [clojure.walk :as walk] [logseq.cli.command.core :as core] [logseq.cli.command.id :as id-command] + [logseq.cli.humanize :as cli-humanize] [logseq.cli.output-mode :as output-mode] [logseq.cli.server :as cli-server] [logseq.cli.style :as style] @@ -855,7 +856,9 @@ (if (seq refs) (str tree-text "\n\n" - "Linked References (" count ")\n" + "Linked References (" + (cli-humanize/format-count count) + ")\n" (linked-refs->text refs uuid->label property-titles property-value-labels)) tree-text))) @@ -870,7 +873,9 @@ [ordered-uuids uuid->entity] (let [ordered-uuids (vec (distinct (remove string/blank? ordered-uuids)))] (when (seq ordered-uuids) - (str "Referenced Entities (" (count ordered-uuids) ")\n" + (str "Referenced Entities (" + (cli-humanize/format-count (count ordered-uuids)) + ")\n" (string/join "\n" (map #(referenced-entity-row % uuid->entity) ordered-uuids)))))) (defn build-action diff --git a/src/main/logseq/cli/format.cljs b/src/main/logseq/cli/format.cljs index cbf7f91e4d..70156782c9 100644 --- a/src/main/logseq/cli/format.cljs +++ b/src/main/logseq/cli/format.cljs @@ -4,6 +4,7 @@ [clojure.string :as string] [clojure.walk :as walk] [logseq.cli.command.core :as command-core] + [logseq.cli.humanize :as cli-humanize] [logseq.cli.output-mode :as output-mode] [logseq.cli.style :as style] [logseq.common.util :as common-util] @@ -162,7 +163,7 @@ (str (render-table headers rows) "\n") (str (string/join "\n" (map (comp string/trimr first) rows)) "\n")) "Count: " - (count rows))) + (cli-humanize/format-count (count rows)))) (defn- missing-search-query-hint [command] @@ -222,22 +223,20 @@ (defn- human-ago [value now-ms] (if-let [ts (parse-ts value)] - (let [diff-ms (max 0 (- now-ms ts)) - secs (js/Math.floor (/ diff-ms 1000)) - mins (js/Math.floor (/ secs 60)) - hours (js/Math.floor (/ mins 60)) - days (js/Math.floor (/ hours 24)) - months (js/Math.floor (/ days 30)) - years (js/Math.floor (/ days 365))] - (cond - (< secs 60) (str secs "s ago") - (< mins 60) (str mins "m ago") - (< hours 24) (str hours "h ago") - (< days 30) (str days "d ago") - (< months 12) (str months "mo ago") - :else (str years "y ago"))) + (cli-humanize/relative-ago ts now-ms) "-")) +(defn- format-task-datetime + [value now-ms] + (if-let [ts (parse-ts value)] + (cli-humanize/relative-datetime ts now-ms) + (if (string? value) + (let [text (string/trim value)] + (if (seq text) + text + "-")) + "-"))) + (defn- items-have-key? [items & ks] (some (fn [item] (some #(contains? item %) ks)) items)) @@ -349,9 +348,9 @@ [:status :logseq.property/status]] ["PRIORITY" (fn [item _] (format-task-choice (or (:priority item) (:logseq.property/priority item)) "priority.")) [:priority :logseq.property/priority]] - ["SCHEDULED" (fn [item _] (or (:scheduled item) (:logseq.property/scheduled item) "-")) + ["SCHEDULED" (fn [item now-ms] (format-task-datetime (or (:scheduled item) (:logseq.property/scheduled item)) now-ms)) [:scheduled :logseq.property/scheduled]] - ["DEADLINE" (fn [item _] (or (:deadline item) (:logseq.property/deadline item) "-")) + ["DEADLINE" (fn [item now-ms] (format-task-datetime (or (:deadline item) (:logseq.property/deadline item)) now-ms)) [:deadline :logseq.property/deadline]] ["UPDATED-AT" (fn [item now-ms] (human-ago (or (:updated-at item) (:block/updated-at item)) now-ms)) [:updated-at :block/updated-at]] ["CREATED-AT" (fn [item now-ms] (human-ago (or (:created-at item) (:block/created-at item)) now-ms)) [:created-at :block/created-at]]]) @@ -395,7 +394,7 @@ [["ID" (fn [item _] (or (:db/id item) (:id item))) [:db/id :id]] ["TITLE" (fn [item _] (or (:title item) (:block/title item) (:name item))) [:title :block/title :name]] ["ASSET-TYPE" (fn [item _] (normalize-asset-type (:logseq.property.asset/type item))) [:logseq.property.asset/type]] - ["SIZE" (fn [item _] (or (:logseq.property.asset/size item) "-")) [:logseq.property.asset/size]] + ["SIZE" (fn [item _] (cli-humanize/format-filesize (:logseq.property.asset/size item))) [:logseq.property.asset/size]] ["UPDATED-AT" (fn [item now-ms] (human-ago (or (:updated-at item) (:block/updated-at item)) now-ms)) [:updated-at :block/updated-at]] ["CREATED-AT" (fn [item now-ms] (human-ago (or (:created-at item) (:block/created-at item)) now-ms)) [:created-at :block/created-at]]]) @@ -496,7 +495,12 @@ base-output (format-counted-table nil rows) legacy-items (filterv legacy-graph-item? graph-items)] (if (seq legacy-items) - (let [warning-lines (vec (concat [(str "Warning: " (count legacy-items) " legacy graph directories detected.")] + (let [legacy-count (count legacy-items) + warning-lines (vec (concat [(str "Warning: " + (cli-humanize/format-count legacy-count) + " legacy graph " + (cli-humanize/pluralize-noun legacy-count "directory") + " detected.")] (mapcat #(format-legacy-warning-lines % data-dir) legacy-items)))] (str base-output "\n\n" (string/join "\n" warning-lines))) base-output))) @@ -631,12 +635,12 @@ skipped-owner-targets (vec (or skipped-owner-targets [])) header-lines [(str "Server cleanup summary") (str "CLI revision: " (normalize-cell cli-revision)) - (str "Checked: " (or checked 0)) - (str "Mismatched: " (or mismatched 0)) - (str "Eligible (:cli owner): " (or eligible 0)) - (str "Skipped owner mismatch: " (or skipped-owner 0)) - (str "Killed: " (count (or killed []))) - (str "Failed: " (count failed))] + (str "Checked: " (cli-humanize/format-count (or checked 0))) + (str "Mismatched: " (cli-humanize/format-count (or mismatched 0))) + (str "Eligible (:cli owner): " (cli-humanize/format-count (or eligible 0))) + (str "Skipped owner mismatch: " (cli-humanize/format-count (or skipped-owner 0))) + (str "Killed: " (cli-humanize/format-count (count (or killed [])))) + (str "Failed: " (cli-humanize/format-count (count failed)))] skipped-lines (when (seq skipped-owner-targets) (into ["" "Skipped owner targets:"] (mapv format-server-cleanup-target skipped-owner-targets))) @@ -721,11 +725,15 @@ (str "repo: " (or repo "-")) (str "graph-id: " (or graph-id "-")) (str "ws-state: " (or ws-state :unknown)) - (str "pending-local: " (or pending-local 0)) - (str "pending-asset: " (or pending-asset 0)) - (str "pending-server: " (or pending-server 0)) - (str "local-tx: " (or local-tx "-")) - (str "remote-tx: " (or remote-tx "-"))] + (str "pending-local: " (cli-humanize/format-count (or pending-local 0))) + (str "pending-asset: " (cli-humanize/format-count (or pending-asset 0))) + (str "pending-server: " (cli-humanize/format-count (or pending-server 0))) + (str "local-tx: " (if (number? local-tx) + (cli-humanize/format-count local-tx) + "-")) + (str "remote-tx: " (if (number? remote-tx) + (cli-humanize/format-count remote-tx) + "-"))] last-error-line (conj last-error-line))))) (defn- format-sync-remote-graphs @@ -794,10 +802,10 @@ (if (vector? result) (str "Upserted blocks:\n" (pr-str (vec (or result [])))) (let [change-parts (cond-> [] - (seq update-tags) (conj (str "tags:+" (count update-tags))) - (seq update-properties) (conj (str "properties:+" (count update-properties))) - (seq remove-tags) (conj (str "remove-tags:+" (count remove-tags))) - (seq remove-properties) (conj (str "remove-properties:+" (count remove-properties)))) + (seq update-tags) (conj (str "tags:+" (cli-humanize/format-count (count update-tags)))) + (seq update-properties) (conj (str "properties:+" (cli-humanize/format-count (count update-properties)))) + (seq remove-tags) (conj (str "remove-tags:+" (cli-humanize/format-count (count remove-tags)))) + (seq remove-properties) (conj (str "remove-properties:+" (cli-humanize/format-count (count remove-properties))))) changes (when (seq change-parts) (str ", " (string/join ", " change-parts))) move-fragment (when (seq target) @@ -828,7 +836,7 @@ [{:keys [repo uuid id ids]}] (cond (seq uuid) (str "Removed block: " uuid " (repo: " repo ")") - (seq ids) (str "Removed blocks: " (count ids) " (repo: " repo ")") + (seq ids) (str "Removed blocks: " (cli-humanize/format-count (count ids)) " (repo: " repo ")") (some? id) (str "Removed block: " id " (repo: " repo ")") :else (str "Removed block (repo: " repo ")"))) diff --git a/src/main/logseq/cli/humanize.cljs b/src/main/logseq/cli/humanize.cljs new file mode 100644 index 0000000000..809bad83ae --- /dev/null +++ b/src/main/logseq/cli/humanize.cljs @@ -0,0 +1,82 @@ +(ns logseq.cli.humanize + "CLI-local wrappers around clj-commons humanize helpers." + (:require [clj-commons.humanize :as humanize] + [clj-commons.humanize.inflect :as humanize-inflect] + [clojure.string :as string])) + +(def ^:private relative-unit->abbr + {"second" "s" + "minute" "m" + "hour" "h" + "day" "d" + "week" "w" + "month" "mo" + "year" "y"}) + +(defn format-count + [value] + (let [n (if (number? value) value 0)] + (humanize/intcomma (js/Math.floor n)))) + +(defn pluralize-noun + [count noun] + (humanize-inflect/pluralize-noun (or count 0) noun)) + +(defn format-count-with-noun + [count noun] + (str (format-count count) + " " + (pluralize-noun count noun))) + +(defn format-filesize + [byte-count] + (if (number? byte-count) + (humanize/filesize byte-count :binary true :format "%.1f") + "-")) + +(defn- unit->abbr + [unit] + (let [unit* (-> unit string/lower-case (string/replace #"s$" ""))] + (get relative-unit->abbr unit* unit*))) + +(defn- abbreviate-relative-datetime + [result] + (let [result (string/trim (or result ""))] + (or (when-let [[_ n unit] (re-matches #"^(\d+)\s+([a-zA-Z]+)\s+ago$" result)] + (str n (unit->abbr unit) " ago")) + (when-let [[_ n unit] (re-matches #"^in\s+(\d+)\s+([a-zA-Z]+)$" result)] + (str "in " n (unit->abbr unit))) + (when (= "in 0s" result) + result) + result))) + +(defn relative-datetime + [then-ms now-ms] + (cond + (not (number? then-ms)) "-" + (not (number? now-ms)) "-" + (<= then-ms 0) "-" + :else + (-> (humanize/relative-datetime (js/Date. then-ms) + :now-dt (js/Date. now-ms) + :max-terms 1 + :number-format str + :short-text "0s" + :prefix "in" + :suffix "ago") + abbreviate-relative-datetime))) + +(defn relative-ago + [then-ms now-ms] + (cond + (not (number? then-ms)) "-" + (not (number? now-ms)) "-" + (<= then-ms 0) "-" + (>= then-ms now-ms) "0s ago" + :else + (let [result (relative-datetime then-ms now-ms)] + (if (string/starts-with? result "in ") + "0s ago" + result)))) + + diff --git a/src/test/logseq/cli/command/doctor_test.cljs b/src/test/logseq/cli/command/doctor_test.cljs index 95249238fb..6fc9a9d294 100644 --- a/src/test/logseq/cli/command/doctor_test.cljs +++ b/src/test/logseq/cli/command/doctor_test.cljs @@ -174,4 +174,32 @@ (is (string/ends-with? checked-path "/dist/db-worker-node.js")))) (p/catch (fn [e] (is false (str "unexpected error: " e)))) - (p/finally done)))) \ No newline at end of file + (p/finally done)))) + +(deftest test-check-running-servers-formats-large-count + (async done + (let [servers (mapv (fn [idx] + {:repo (str "logseq_db_graph-" idx) + :status :starting}) + (range 1234))] + (-> (p/with-redefs [cli-server/list-servers (fn [_] (p/resolved servers))] + (p/let [result (#'doctor-command/check-running-servers {}) + message (get-in result [:check :message])] + (is (= true (:warning? result))) + (is (string/includes? message "1,234 servers still starting:")))) + (p/catch (fn [e] + (is false (str "unexpected error: " e)))) + (p/finally done))))) + +(deftest test-check-server-revision-mismatch-formats-large-count + (let [mismatch-servers (mapv (fn [idx] + {:repo (str "logseq_db_graph-" idx) + :revision "worker-rev"}) + (range 1234)) + result (with-redefs [cli-server/compute-revision-mismatches + (fn [_ _] + {:servers mismatch-servers})] + (#'doctor-command/check-server-revision-mismatch "cli-rev" []))] + (is (= true (:warning? result))) + (is (string/includes? (get-in result [:check :message]) + "1,234 servers use a different revision than this CLI")))) \ No newline at end of file diff --git a/src/test/logseq/cli/command/example_test.cljs b/src/test/logseq/cli/command/example_test.cljs index e0d3e5babe..58aad91018 100644 --- a/src/test/logseq/cli/command/example_test.cljs +++ b/src/test/logseq/cli/command/example_test.cljs @@ -80,3 +80,13 @@ result (example-command/build-action mock-base-table ["example" "upsert"])] (is (false? (:ok? result))) (is (= :missing-examples (get-in result [:error :code])))))) + +(deftest test-build-action-formats-large-example-count + (let [mock-base-table [{:cmds ["upsert" "page"] + :examples (mapv (fn [idx] + (str "logseq upsert page --graph demo --page Page-" idx)) + (range 1234))}] + result (example-command/build-action mock-base-table ["example" "upsert" "page"])] + (is (true? (:ok? result))) + (is (= "Found 1,234 examples for selector upsert page" + (get-in result [:action :message]))))) diff --git a/src/test/logseq/cli/command/graph_test.cljs b/src/test/logseq/cli/command/graph_test.cljs index 6d2713e4f7..38efd5e8c7 100644 --- a/src/test/logseq/cli/command/graph_test.cljs +++ b/src/test/logseq/cli/command/graph_test.cljs @@ -22,6 +22,17 @@ "Found 1 entity with errors:")) (is (= :ok (:status valid-result))))) +(deftest test-graph-validate-result-formats-large-error-count + (let [graph-validate-result #'graph-command/graph-validate-result + errors (mapv (fn [idx] + {:entity {:db/id idx} + :errors {:foo ["bad"]}}) + (range 1234)) + invalid-result (graph-validate-result {:errors errors})] + (is (= :error (:status invalid-result))) + (is (string/includes? (get-in invalid-result [:error :message]) + "Found 1,234 entities with errors:")))) + (deftest test-execute-graph-info-queries-kv-rows-with-thread-api-q (async done (let [invoke-calls* (atom []) diff --git a/src/test/logseq/cli/command/show_test.cljs b/src/test/logseq/cli/command/show_test.cljs index 3ee47558fa..3d3056d377 100644 --- a/src/test/logseq/cli/command/show_test.cljs +++ b/src/test/logseq/cli/command/show_test.cljs @@ -236,6 +236,16 @@ {(string/lower-case u1) {:label "Broken ref"} (string/lower-case u2) {:id 88}})))))) +(deftest test-render-referenced-entities-footer-formats-large-count + (let [render-footer (fn [ordered-uuids uuid->entity] + (call-private 'render-referenced-entities-footer ordered-uuids uuid->entity)) + ordered-uuids (mapv (fn [idx] + (str "uuid-" idx)) + (range 1234)) + uuid->entity {} + output (render-footer ordered-uuids uuid->entity)] + (is (string/includes? output "Referenced Entities (1,234)")))) + (deftest test-build-action-ref-id-footer (testing "ref-id-footer defaults to true" (let [result (show-command/build-action {:id "42"} diff --git a/src/test/logseq/cli/format_test.cljs b/src/test/logseq/cli/format_test.cljs index f45fe59fa3..37056aea58 100644 --- a/src/test/logseq/cli/format_test.cljs +++ b/src/test/logseq/cli/format_test.cljs @@ -177,7 +177,30 @@ :conflict? false}]}} {:output-format nil :data-dir "/tmp/graphs"})] - (is (string/includes? result "mv '/tmp/graphs/weird'\"'\"'++name' '/tmp/graphs/weird~27~2Fname'"))))) + (is (string/includes? result "mv '/tmp/graphs/weird'\"'\"'++name' '/tmp/graphs/weird~27~2Fname'")))) + + ) + +(deftest test-human-output-graph-list-count-grouping + (let [graphs (mapv #(str "graph-" %) (range 1234)) + result (format/format-result {:status :ok + :command :graph-list + :data {:graphs graphs}} + {:output-format nil})] + (is (string/includes? result "Count: 1,234")))) + +(deftest test-human-output-graph-list-legacy-warning-singular + (let [result (format/format-result {:status :ok + :command :graph-list + :data {:graphs ["legacy/name"] + :graph-items [{:kind :legacy + :legacy-dir "legacy++name" + :legacy-graph-name "legacy/name" + :target-graph-dir "legacy~2Fname" + :conflict? false}]}} + {:output-format nil + :data-dir "/tmp/graphs"})] + (is (string/includes? result "Warning: 1 legacy graph directory detected.")))) (deftest test-human-output-list-page (testing "list page renders a table with count" @@ -265,7 +288,26 @@ (is (string/includes? result "SCHEDULED")) (is (string/includes? result "DEADLINE")) (is (string/includes? result "Alpha task")) - (is (string/includes? result "Count: 1")))) + (is (string/includes? result "Count: 1"))) + + (testing "list task renders epoch-ms scheduled/deadline with humanized relative datetime" + (let [now-ms 1000000000 + scheduled-ms (+ now-ms (* 3 60 60 1000)) + deadline-ms (- now-ms (* 2 24 60 60 1000)) + result (format/format-result {:status :ok + :command :list-task + :data {:items [{:db/id 226 + :block/title "q3" + :logseq.property/status :logseq.property/status.todo + :logseq.property/priority :logseq.property/priority.high + :logseq.property/scheduled scheduled-ms + :logseq.property/deadline deadline-ms}]}} + {:output-format nil + :now-ms now-ms})] + (is (string/includes? result "in ")) + (is (string/includes? result "ago")) + (is (not (string/includes? result (str scheduled-ms)))) + (is (not (string/includes? result (str deadline-ms))))))) (deftest test-human-output-list-node (let [result (format/format-result {:status :ok @@ -306,11 +348,18 @@ :block/updated-at 90000}]}} {:output-format nil :now-ms 100000}) - lines (string/split-lines result)] - (is (= "ID TITLE ASSET-TYPE SIZE UPDATED-AT CREATED-AT" (first lines))) + lines (string/split-lines result) + header (first lines)] + (is (string/includes? header "ID")) + (is (string/includes? header "TITLE")) + (is (string/includes? header "ASSET-TYPE")) + (is (string/includes? header "SIZE")) + (is (string/includes? header "UPDATED-AT")) + (is (string/includes? header "CREATED-AT")) (is (string/includes? result "Asset Node")) (is (string/includes? result "md")) - (is (string/includes? result "2552")) + (is (not (string/includes? result "2552"))) + (is (some? (re-find #"\b2(\.\d+)?\s*[KM]i?B\b" result))) (is (not (string/includes? (first lines) " TYPE "))) (is (not (string/includes? result "PAGE-ID"))) (is (not (string/includes? result "PAGE-TITLE"))) @@ -530,6 +579,13 @@ :ids [1 2 3]} :data {:result {:ok true}}} "Removed blocks: 3 (repo: demo-repo)"] + ["remove block with large id list uses grouped count" + {:status :ok + :command :remove-block + :context {:repo "demo-repo" + :ids (vec (range 1234))} + :data {:result {:ok true}}} + "Removed blocks: 1,234 (repo: demo-repo)"] ["remove tag renders a succinct success line" {:status :ok :command :remove-tag @@ -559,6 +615,14 @@ :remove-tags ["TagB"] :remove-properties [:logseq.property/deadline]} "Upserted block: source-uuid -> target-uuid (repo: demo-repo, tags:+1, properties:+1, remove-tags:+1, remove-properties:+1)"] + ["upsert block update uses grouped counts for large changes" + {:repo "demo-repo" + :source "source-uuid" + :update-tags (vec (range 1234)) + :update-properties (zipmap (map #(keyword (str "prop-" %)) (range 1234)) (repeat true)) + :remove-tags (vec (range 1234)) + :remove-properties (mapv #(keyword (str "remove-" %)) (range 1234))} + "Upserted block: source-uuid (repo: demo-repo, tags:+1,234, properties:+1,234, remove-tags:+1,234, remove-properties:+1,234)"] ["upsert block update without move target renders a succinct success line" {:repo "demo-repo" :source "source-uuid" @@ -657,18 +721,20 @@ :data {:repo "demo-graph" :graph-id "graph-uuid" :ws-state :open - :pending-local 2 - :pending-asset 1 - :pending-server 3 - :local-tx 10 - :remote-tx 13}} + :pending-local 2345 + :pending-asset 1234 + :pending-server 9876 + :local-tx 12345 + :remote-tx 67890}} {:output-format nil})] (is (string/includes? result "Sync status")) (is (string/includes? result "demo-graph")) (is (string/includes? result "graph-uuid")) - (is (string/includes? result "pending-local")) - (is (string/includes? result "pending-asset")) - (is (string/includes? result "pending-server")))) + (is (string/includes? result "pending-local: 2,345")) + (is (string/includes? result "pending-asset: 1,234")) + (is (string/includes? result "pending-server: 9,876")) + (is (string/includes? result "local-tx: 12,345")) + (is (string/includes? result "remote-tx: 67,890")))) (testing "sync status renders last error diagnostic when present" (let [result (format/format-result {:status :ok @@ -900,18 +966,20 @@ (let [result (format/format-result {:status :ok :command :server-cleanup :data {:cli-revision "cli-rev" - :checked 4 - :mismatched 3 - :eligible 2 - :skipped-owner 1 + :checked 4321 + :mismatched 3210 + :eligible 2100 + :skipped-owner 1111 :skipped-owner-targets [{:repo "logseq_db_graph-b" :pid 22 :owner-source :electron :revision "worker-rev-b"}] - :killed [{:repo "logseq_db_graph-a" - :pid 11 - :owner-source :cli - :revision "worker-rev-a"}] + :killed (mapv (fn [idx] + {:repo (str "logseq_db_graph-killed-" idx) + :pid (+ 1000 idx) + :owner-source :cli + :revision "worker-rev-a"}) + (range 1234)) :failed [{:repo "logseq_db_graph-c" :pid 33 :owner-source :cli @@ -921,11 +989,11 @@ {:output-format nil})] (is (string/includes? result "Server cleanup summary")) (is (string/includes? result "CLI revision: cli-rev")) - (is (string/includes? result "Checked: 4")) - (is (string/includes? result "Mismatched: 3")) - (is (string/includes? result "Eligible (:cli owner): 2")) - (is (string/includes? result "Skipped owner mismatch: 1")) - (is (string/includes? result "Killed: 1")) + (is (string/includes? result "Checked: 4,321")) + (is (string/includes? result "Mismatched: 3,210")) + (is (string/includes? result "Eligible (:cli owner): 2,100")) + (is (string/includes? result "Skipped owner mismatch: 1,111")) + (is (string/includes? result "Killed: 1,234")) (is (string/includes? result "Failed: 1")) (is (string/includes? result "Skipped owner targets:")) (is (string/includes? result "graph-b"))