mirror of
https://github.com/logseq/logseq.git
synced 2026-04-24 22:25:01 +00:00
018-logseq-cli-add-tags-builtin-properties.md
This commit is contained in:
196
docs/agent-guide/018-logseq-cli-add-tags-builtin-properties.md
Normal file
196
docs/agent-guide/018-logseq-cli-add-tags-builtin-properties.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Logseq CLI Add Tag And Built-in Property Support Implementation Plan
|
||||
|
||||
Goal: Extend logseq-cli add block and add page to accept tags and built-in properties with correct type handling.
|
||||
|
||||
Architecture: Parse tag and property options in the CLI, resolve them via db-worker-node, and apply them using existing outliner ops.
|
||||
Architecture: Use built-in property definitions and property type rules to coerce values before invoking :batch-set-property or :create-page.
|
||||
|
||||
Tech Stack: ClojureScript, babashka.cli, Datascript, db-worker-node, outliner ops.
|
||||
|
||||
Related: Relates to docs/agent-guide/015-logseq-cli-db-worker-node-housekeeping.md.
|
||||
|
||||
## Problem statement
|
||||
|
||||
Logseq CLI currently supports add block and add page with content and basic status support.
|
||||
Users need to set tags and built-in properties at creation time from CLI, but built-in properties have multiple value types and validation rules.
|
||||
The implementation must align with the built-in property definitions and property type system so that values are stored and validated correctly in db-worker-node.
|
||||
|
||||
## Testing Plan
|
||||
|
||||
I will add unit tests that parse new CLI options for tags and properties and validate error cases for invalid property names and invalid type values.
|
||||
I will add integration tests that run add block and add page with tags and built-in properties and assert the resulting data in the graph.
|
||||
I will add tests that cover ref-type properties like :logseq.property/deadline and :block/tags to ensure resolution behavior is correct.
|
||||
I will add tests that cover scalar properties like :logseq.property/publishing-public? and :logseq.property/heading with proper type coercion.
|
||||
I will verify that invalid types cause CLI errors before any outliner ops are sent.
|
||||
I will add tests that missing tags in --add-tag fail with a clear error and do not create tags.
|
||||
NOTE: I will write *all* tests before I add any implementation behavior.
|
||||
|
||||
## Current behavior summary
|
||||
|
||||
Add block and add page live in src/main/logseq/cli/command/add.cljs.
|
||||
Add block supports content, blocks, blocks-file, target selection, position, and status, and it sets status via :batch-set-property.
|
||||
Add page uses :create-page with an empty options map and does not apply tags or properties.
|
||||
Built-in properties and their schema are defined in deps/db/src/logseq/db/frontend/property.cljs and type rules in deps/db/src/logseq/db/frontend/property/type.cljs.
|
||||
|
||||
## Requirements
|
||||
|
||||
Add block supports setting tags on all inserted blocks.
|
||||
Add page supports setting tags on the created page.
|
||||
Add block supports setting built-in properties with correct type coercion.
|
||||
Add page supports setting built-in properties with correct type coercion.
|
||||
CLI rejects non built-in properties for these new options.
|
||||
CLI rejects built-in properties that are not public and provides a clear error message.
|
||||
CLI rejects adding non public tags to blocks.
|
||||
CLI rejects combining --blocks or --blocks-file with --add-property, --remove-property, --add-tag, or --remove-tag.
|
||||
|
||||
## Non goals
|
||||
|
||||
Do not change db-worker-node HTTP APIs or add new endpoints.
|
||||
Do not change outliner property validation logic.
|
||||
Do not add support for user properties in this change.
|
||||
|
||||
## Built-in property type considerations
|
||||
|
||||
Built-in property configuration lives in deps/db/src/logseq/db/frontend/property.cljs.
|
||||
Built-in property types and validation live in deps/db/src/logseq/db/frontend/property/type.cljs.
|
||||
Property types include user types (:default, :number, :date, :datetime, :checkbox, :url, :node) and internal types (:string, :keyword, :map, :coll, :any, :entity, :class, :page, :property, :raw-number).
|
||||
Some built-in properties are not public and must not be user-settable via CLI.
|
||||
|
||||
### Value coercion table
|
||||
|
||||
| Property type | Expected CLI input | Resolution behavior |
|
||||
| --- | --- | --- |
|
||||
| :checkbox | boolean or "true"/"false" | Coerce to boolean and send directly. |
|
||||
| :number | number or numeric string | Coerce to number and send directly. |
|
||||
| :raw-number | number | Send directly and reject non numeric. |
|
||||
| :datetime | ISO string | Convert to epoch ms number before sending. |
|
||||
| :date | date string or journal page name | Resolve to journal page entity id. |
|
||||
| :default | string or EDN | If string, pass as text value and let outliner create property value block. |
|
||||
| :url | string | Validate with db-property-type/url? or allow macro urls and send as string. |
|
||||
| :string | string | Send directly. |
|
||||
| :keyword | keyword or string | Coerce string to keyword if safe. |
|
||||
| :map | EDN map | Send directly. |
|
||||
| :coll | EDN vector or list | Send directly. |
|
||||
| :entity | block uuid or db/id | Resolve to entity id with :thread-api/pull or reject. |
|
||||
| :page | page name or uuid | Resolve to page entity id, create page if missing. |
|
||||
| :class | tag name or uuid | Resolve to class entity id and fail if missing. |
|
||||
| :property | property name or keyword | Resolve to property entity id using built-in properties map. |
|
||||
| :node | block uuid or page name | Resolve to entity id or allow node text block if required. |
|
||||
| :any | EDN value | Send directly. |
|
||||
|
||||
## Data flow overview
|
||||
|
||||
CLI input is parsed by babashka.cli and normalized in src/main/logseq/cli/command/add.cljs.
|
||||
CLI resolves tags and property values via db-worker-node using :thread-api/pull and existing outliner ops.
|
||||
CLI applies properties using :batch-set-property for blocks and :create-page options for pages.
|
||||
|
||||
ASCII architecture sketch.
|
||||
|
||||
CLI add command
|
||||
-> parse options
|
||||
-> resolve tags and properties
|
||||
-> db-worker-node :thread-api/apply-outliner-ops
|
||||
-> outliner ops set properties and tags
|
||||
|
||||
## Design decisions
|
||||
|
||||
Tags are applied through :block/tags because it is the built-in tags property and is validated by outliner validation.
|
||||
Page creation uses :create-page with :tags and :properties options to avoid separate post-create transactions.
|
||||
Block creation uses existing insert blocks then batch-set-property for tags and each built-in property to keep behavior consistent with status handling.
|
||||
Tags are always written to :block/tags and never to :logseq.property/page-tags.
|
||||
Add block rejects any combination of --blocks or --blocks-file with --add-property, --remove-property, --add-tag, or --remove-tag.
|
||||
Properties provided via CLI apply to all inserted blocks unless the input blocks already include explicit values in their block maps.
|
||||
Properties provided via CLI override existing values on the newly created blocks to avoid ambiguity.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
### 1. CLI option design and parsing
|
||||
|
||||
Add new options to content-add-spec in src/main/logseq/cli/command/add.cljs for tags and properties.
|
||||
Add new options to add-page-spec in src/main/logseq/cli/command/add.cljs for tags and properties.
|
||||
Use a single EDN map option like --properties for multiple properties and a repeated option like --property for key value pairs if needed.
|
||||
Use a single EDN vector option like --tags and a repeated option like --tag for convenience.
|
||||
Update command summary and help output in src/main/logseq/cli/command/core.cljs if needed to reflect new options.
|
||||
|
||||
### 2. Tag resolution helpers
|
||||
|
||||
Add a helper in src/main/logseq/cli/command/add.cljs to normalize tag inputs into a vector of tag names or uuids.
|
||||
Add a helper that validates each tag exists and fails fast when a tag is missing.
|
||||
Use :thread-api/pull with lookup refs to resolve existing tag pages by name or uuid.
|
||||
Return a vector of tag entity ids or uuids suitable for outliner ops.
|
||||
|
||||
### 3. Built-in property resolution helpers
|
||||
|
||||
Add a helper in src/main/logseq/cli/command/add.cljs to parse --properties EDN and validate keys against logseq.db.frontend.property/built-in-properties.
|
||||
Reject keys not present in built-in-properties or not public based on :schema :public?.
|
||||
Add a helper to get property type from built-in-properties and then coerce values using rules in deps/db/src/logseq/db/frontend/property/type.cljs.
|
||||
Add a helper to resolve ref values into entity ids via :thread-api/pull for :page, :class, :property, :entity, and :node.
|
||||
Add a helper to resolve :date to a journal page, creating it when missing if that is consistent with UI behavior.
|
||||
Do not create tags implicitly when missing for --add-tag.
|
||||
|
||||
### 4. Add page execution changes
|
||||
|
||||
Extend build-add-page-action to carry tags and properties in the action context.
|
||||
Modify execute-add-page in src/main/logseq/cli/command/add.cljs to pass :tags and :properties into the :create-page op options map.
|
||||
Ensure property values are coerced before being sent so outliner validation passes.
|
||||
|
||||
### 5. Add block execution changes
|
||||
|
||||
Extend build-add-block-action to carry tags and properties in the action context.
|
||||
After insert blocks and status application, apply tags via :batch-set-property with :block/tags and the resolved tag ids.
|
||||
Apply each built-in property via :batch-set-property for the newly created block uuids.
|
||||
Keep :keep-uuid? behavior for status so tags and properties can reference inserted block ids.
|
||||
|
||||
### 6. CLI formatting and errors
|
||||
|
||||
Update error messages in src/main/logseq/cli/commands.cljs to include new invalid option errors.
|
||||
Add error formatting in src/main/logseq/cli/format.cljs if needed to show applied tags or properties in human output.
|
||||
Ensure JSON output includes any new context if the CLI returns it.
|
||||
|
||||
### 7. Tests and fixtures
|
||||
|
||||
Add unit tests in src/test/logseq/cli/commands_test.cljs for option parsing and error handling.
|
||||
Add integration tests in src/test/logseq/cli/integration_test.cljs that create blocks and pages with tags and built-in properties.
|
||||
Add tests for at least one ref property (e.g. :logseq.property/deadline) and one scalar property (e.g. :logseq.property/publishing-public?).
|
||||
Add tests for tag creation when tag pages do not exist.
|
||||
|
||||
## Edge cases
|
||||
|
||||
Tags that collide with private or built-in non-tag classes should be rejected by validation and surfaced to the CLI user.
|
||||
Missing tags in --add-tag should produce a clear missing tag error without creating new tag pages.
|
||||
Properties with closed values like :logseq.property/status should accept keyword idents as well as string labels where supported.
|
||||
Date properties must resolve to journal pages or fail with a clear error if parsing is invalid.
|
||||
Properties with cardinality many should accept vectors and sets and maintain ordering when required.
|
||||
Inline tags or page namespaces should not be created implicitly without validation of allowed characters.
|
||||
|
||||
## Resolved decisions
|
||||
|
||||
CLI must not allow setting non public built-in properties, even with a force option.
|
||||
Tags are applied via :block/tags and not :logseq.property/page-tags.
|
||||
Add block must reject --blocks or --blocks-file when combined with --add-property, --remove-property, --add-tag, or --remove-tag.
|
||||
Datetime values are provided as ISO strings.
|
||||
|
||||
## Testing Details
|
||||
|
||||
I will add CLI tests that run add page and add block end to end and assert the actual persisted properties and tags using list or show commands.
|
||||
I will add tests that confirm invalid inputs fail fast and do not produce partial writes.
|
||||
I will add tests that assert correct ref resolution for tag pages and journal pages.
|
||||
I will ensure tests cover behavior rather than internal data structures and follow @test-driven-development.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
- Update src/main/logseq/cli/command/add.cljs with new option parsing and action fields.
|
||||
- Add tag and property normalization helpers in src/main/logseq/cli/command/add.cljs.
|
||||
- Use deps/db/src/logseq/db/frontend/property.cljs to validate built-in property keys.
|
||||
- Use deps/db/src/logseq/db/frontend/property/type.cljs to coerce values by type.
|
||||
- Use :thread-api/pull in src/main/logseq/cli/command/add.cljs to resolve pages, tags, properties, and blocks.
|
||||
- Pass tags and properties into :create-page ops in src/main/logseq/cli/command/add.cljs.
|
||||
- Apply :batch-set-property for :block/tags and built-in properties in src/main/logseq/cli/command/add.cljs.
|
||||
- Update src/test/logseq/cli/commands_test.cljs with parsing validation tests.
|
||||
- Update src/test/logseq/cli/integration_test.cljs with behavior tests for tags and built-in properties.
|
||||
|
||||
## Question
|
||||
|
||||
No open questions.
|
||||
|
||||
---
|
||||
@@ -10,12 +10,16 @@
|
||||
[logseq.common.util :as common-util]
|
||||
[logseq.common.util.date-time :as date-time-util]
|
||||
[logseq.common.uuid :as common-uuid]
|
||||
[logseq.db.frontend.property :as db-property]
|
||||
[logseq.db.frontend.property.type :as db-property-type]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(def ^:private content-add-spec
|
||||
{:content {:desc "Block content for add"}
|
||||
:blocks {:desc "EDN vector of blocks for add"}
|
||||
:blocks-file {:desc "EDN file of blocks for add"}
|
||||
:tags {:desc "EDN vector of tags"}
|
||||
:properties {:desc "EDN map of built-in properties"}
|
||||
:target-id {:desc "Target block db/id"
|
||||
:coerce :long}
|
||||
:target-uuid {:desc "Target block UUID"}
|
||||
@@ -24,7 +28,9 @@
|
||||
:status {:desc "Task status (todo, doing, done, etc.)"}})
|
||||
|
||||
(def ^:private add-page-spec
|
||||
{:page {:desc "Page name"}})
|
||||
{:page {:desc "Page name"}
|
||||
:tags {:desc "EDN vector of tags"}
|
||||
:properties {:desc "EDN map of built-in properties"}})
|
||||
|
||||
(def entries
|
||||
[(core/command-entry ["add" "block"] :add-block "Add blocks" content-add-spec)
|
||||
@@ -103,13 +109,249 @@
|
||||
(assoc block :block/uuid (common-uuid/gen-uuid)))))
|
||||
blocks))
|
||||
|
||||
(defn- invalid-options-result
|
||||
[message]
|
||||
{:ok? false
|
||||
:error {:code :invalid-options
|
||||
:message message}})
|
||||
|
||||
(defn- parse-edn-option
|
||||
[value]
|
||||
(when (seq value)
|
||||
(common-util/safe-read-string {:log-error? false} value)))
|
||||
|
||||
(defn- keyword->name
|
||||
[value]
|
||||
(subs (str value) 1))
|
||||
|
||||
(defn- normalize-tag-value
|
||||
[value]
|
||||
(cond
|
||||
(uuid? value) value
|
||||
(and (string? value) (common-util/uuid-string? (string/trim value)))
|
||||
(uuid (string/trim value))
|
||||
(keyword? value) (keyword->name value)
|
||||
(string? value) (let [text (-> value string/trim (string/replace #"^#+" ""))]
|
||||
(when (seq text) text))
|
||||
:else nil))
|
||||
|
||||
(defn- parse-tags-option
|
||||
[value]
|
||||
(if-not (seq value)
|
||||
{:ok? true :value nil}
|
||||
(let [parsed (parse-edn-option value)]
|
||||
(cond
|
||||
(nil? parsed)
|
||||
(invalid-options-result "tags must be valid EDN vector")
|
||||
|
||||
(not (vector? parsed))
|
||||
(invalid-options-result "tags must be a vector")
|
||||
|
||||
(empty? parsed)
|
||||
(invalid-options-result "tags must be a non-empty vector")
|
||||
|
||||
:else
|
||||
(let [tags (mapv normalize-tag-value parsed)]
|
||||
(if (some nil? tags)
|
||||
(invalid-options-result "tags must be strings, keywords, or uuids")
|
||||
{:ok? true :value tags}))))))
|
||||
|
||||
(defn- normalize-property-key
|
||||
[value]
|
||||
(cond
|
||||
(keyword? value) value
|
||||
(string? value)
|
||||
(let [text (string/trim value)]
|
||||
(cond
|
||||
(string/blank? text) nil
|
||||
(common-util/valid-edn-keyword? text)
|
||||
(common-util/safe-read-string {:log-error? false} text)
|
||||
:else (keyword text)))
|
||||
:else nil))
|
||||
|
||||
(defn- parse-boolean-value
|
||||
[value]
|
||||
(cond
|
||||
(or (true? value) (false? value)) {:ok? true :value value}
|
||||
(string? value) (let [text (string/lower-case (string/trim value))]
|
||||
(cond
|
||||
(= text "true") {:ok? true :value true}
|
||||
(= text "false") {:ok? true :value false}
|
||||
:else {:ok? false}))
|
||||
:else {:ok? false}))
|
||||
|
||||
(defn- parse-number-value
|
||||
[value]
|
||||
(cond
|
||||
(number? value) {:ok? true :value value}
|
||||
(string? value) (let [parsed (js/parseFloat value)]
|
||||
(if (js/isNaN parsed)
|
||||
{:ok? false}
|
||||
{:ok? true :value parsed}))
|
||||
:else {:ok? false}))
|
||||
|
||||
(defn- parse-datetime-value
|
||||
[value]
|
||||
(cond
|
||||
(number? value) {:ok? true :value value}
|
||||
(string? value) (let [date (js/Date. value)
|
||||
ms (.getTime date)]
|
||||
(if (js/isNaN ms)
|
||||
{:ok? false}
|
||||
{:ok? true :value ms}))
|
||||
:else {:ok? false}))
|
||||
|
||||
(defn- parse-keyword-value
|
||||
[value]
|
||||
(cond
|
||||
(keyword? value) {:ok? true :value value}
|
||||
(string? value) (let [text (string/trim value)]
|
||||
(if (string/blank? text)
|
||||
{:ok? false}
|
||||
(if (common-util/valid-edn-keyword? text)
|
||||
(let [parsed (common-util/safe-read-string {:log-error? false} text)]
|
||||
(if (keyword? parsed)
|
||||
{:ok? true :value parsed}
|
||||
{:ok? false}))
|
||||
{:ok? true :value (keyword text)})))
|
||||
:else {:ok? false}))
|
||||
|
||||
(defn- coerce-property-value-basic
|
||||
[property value]
|
||||
(let [type (get-in property [:schema :type] :default)]
|
||||
(cond
|
||||
(nil? value)
|
||||
{:ok? false :message "property value must not be nil"}
|
||||
|
||||
(= type :checkbox)
|
||||
(let [{:keys [ok? value]} (parse-boolean-value value)]
|
||||
(if ok?
|
||||
{:ok? true :value value}
|
||||
{:ok? false :message "checkbox property expects true or false"}))
|
||||
|
||||
(= type :number)
|
||||
(let [{:keys [ok? value]} (parse-number-value value)]
|
||||
(if ok?
|
||||
{:ok? true :value value}
|
||||
{:ok? false :message "number property expects a numeric value"}))
|
||||
|
||||
(= type :raw-number)
|
||||
(if (number? value)
|
||||
{:ok? true :value value}
|
||||
{:ok? false :message "raw-number property expects a number"})
|
||||
|
||||
(= type :datetime)
|
||||
(let [{:keys [ok? value]} (parse-datetime-value value)]
|
||||
(if ok?
|
||||
{:ok? true :value value}
|
||||
{:ok? false :message "datetime property expects an ISO date string"}))
|
||||
|
||||
(= type :keyword)
|
||||
(let [{:keys [ok? value]} (parse-keyword-value value)]
|
||||
(if ok?
|
||||
{:ok? true :value value}
|
||||
{:ok? false :message "keyword property expects a keyword"}))
|
||||
|
||||
(= type :string)
|
||||
(if (string? value)
|
||||
{:ok? true :value value}
|
||||
{:ok? false :message "string property expects a string"})
|
||||
|
||||
(= type :map)
|
||||
(if (map? value)
|
||||
{:ok? true :value value}
|
||||
{:ok? false :message "map property expects a map"})
|
||||
|
||||
(= type :coll)
|
||||
(if (and (coll? value) (not (string? value)))
|
||||
{:ok? true :value (vec value)}
|
||||
{:ok? false :message "coll property expects a collection"})
|
||||
|
||||
(= type :url)
|
||||
(if (and (string? value) (or (db-property-type/url? value) (db-property-type/macro-url? value)))
|
||||
{:ok? true :value value}
|
||||
{:ok? false :message "url property expects a valid url"})
|
||||
|
||||
(= type :date)
|
||||
(if (string? value)
|
||||
{:ok? true :value value}
|
||||
{:ok? false :message "date property expects a date string"})
|
||||
|
||||
(contains? #{:entity :page :class :property :node :default :any} type)
|
||||
{:ok? true :value value}
|
||||
|
||||
:else
|
||||
{:ok? true :value value})))
|
||||
|
||||
(defn- normalize-property-values
|
||||
[property value]
|
||||
(let [many? (= :many (get-in property [:schema :cardinality]))
|
||||
values (if many?
|
||||
(if (and (coll? value) (not (string? value))) value [value])
|
||||
[value])]
|
||||
(loop [remaining values
|
||||
normalized []]
|
||||
(if (empty? remaining)
|
||||
{:ok? true
|
||||
:value (if many? (vec normalized) (first normalized))}
|
||||
(let [result (coerce-property-value-basic property (first remaining))]
|
||||
(if-not (:ok? result)
|
||||
{:ok? false
|
||||
:message (:message result)}
|
||||
(recur (rest remaining) (conj normalized (:value result)))))))))
|
||||
|
||||
(defn- property-public?
|
||||
[property]
|
||||
(true? (get-in property [:schema :public?])))
|
||||
|
||||
(defn- parse-properties-option
|
||||
[value]
|
||||
(if-not (seq value)
|
||||
{:ok? true :value nil}
|
||||
(let [parsed (parse-edn-option value)]
|
||||
(cond
|
||||
(nil? parsed)
|
||||
(invalid-options-result "properties must be valid EDN map")
|
||||
|
||||
(not (map? parsed))
|
||||
(invalid-options-result "properties must be a map")
|
||||
|
||||
(empty? parsed)
|
||||
(invalid-options-result "properties must be a non-empty map")
|
||||
|
||||
:else
|
||||
(loop [entries (seq parsed)
|
||||
acc {}]
|
||||
(if (empty? entries)
|
||||
{:ok? true :value acc}
|
||||
(let [[k v] (first entries)
|
||||
key* (normalize-property-key k)]
|
||||
(if-not key*
|
||||
(invalid-options-result (str "invalid property key: " k))
|
||||
(let [property (get db-property/built-in-properties key*)]
|
||||
(cond
|
||||
(nil? property)
|
||||
(invalid-options-result (str "unknown built-in property: " key*))
|
||||
|
||||
(not (property-public? property))
|
||||
(invalid-options-result (str "property is not public: " key*))
|
||||
|
||||
:else
|
||||
(let [{:keys [ok? value message]} (normalize-property-values property v)]
|
||||
(if-not ok?
|
||||
(invalid-options-result (str "invalid value for " key* ": " message))
|
||||
(recur (rest entries) (assoc acc key* value))))))))))))))
|
||||
|
||||
(defn invalid-options?
|
||||
[opts]
|
||||
(let [pos (some-> (:pos opts) string/trim string/lower-case)
|
||||
target-id (:target-id opts)
|
||||
target-uuid (some-> (:target-uuid opts) string/trim)
|
||||
target-page (some-> (:target-page-name opts) string/trim)
|
||||
target-selectors (filter some? [target-id target-uuid target-page])]
|
||||
target-selectors (filter some? [target-id target-uuid target-page])
|
||||
has-blocks? (or (seq (:blocks opts)) (seq (:blocks-file opts)))
|
||||
has-tags? (seq (some-> (:tags opts) string/trim))
|
||||
has-properties? (seq (some-> (:properties opts) string/trim))]
|
||||
(cond
|
||||
(and (seq pos) (not (contains? add-positions pos)))
|
||||
(str "invalid pos: " (:pos opts))
|
||||
@@ -120,9 +362,145 @@
|
||||
(and (= pos "sibling") (or (seq target-page) (empty? target-selectors)))
|
||||
"--pos sibling is only valid for block targets"
|
||||
|
||||
(and has-blocks? (or has-tags? has-properties?))
|
||||
"tags and properties cannot be combined with --blocks or --blocks-file"
|
||||
|
||||
:else
|
||||
nil)))
|
||||
|
||||
(defn- pull-entity
|
||||
[config repo selector lookup]
|
||||
(transport/invoke config :thread-api/pull false [repo selector lookup]))
|
||||
|
||||
(defn- tag-lookup-ref
|
||||
[tag]
|
||||
(cond
|
||||
(uuid? tag) [:block/uuid tag]
|
||||
(and (string? tag) (common-util/uuid-string? (string/trim tag))) [:block/uuid (uuid (string/trim tag))]
|
||||
(string? tag) [:block/name (common-util/page-name-sanity-lc tag)]
|
||||
:else nil))
|
||||
|
||||
(defn- resolve-tag-entity
|
||||
[config repo tag]
|
||||
(let [lookup (tag-lookup-ref tag)]
|
||||
(when-not lookup
|
||||
(throw (ex-info "invalid tag value" {:code :invalid-tag :tag tag})))
|
||||
(p/let [entity (pull-entity config repo
|
||||
[:db/id :block/name :block/title :block/uuid :block/tags
|
||||
:logseq.property/public? :logseq.property/built-in?]
|
||||
lookup)]
|
||||
(cond
|
||||
(nil? (:db/id entity))
|
||||
(throw (ex-info "tag not found" {:code :tag-not-found :tag tag}))
|
||||
|
||||
(false? (:logseq.property/public? entity))
|
||||
(throw (ex-info "tag is not public" {:code :tag-not-public :tag tag}))
|
||||
|
||||
:else
|
||||
entity))))
|
||||
|
||||
(defn- resolve-tags
|
||||
[config repo tags]
|
||||
(if (seq tags)
|
||||
(p/let [entities (p/all (map #(resolve-tag-entity config repo %) tags))]
|
||||
(vec entities))
|
||||
(p/resolved nil)))
|
||||
|
||||
(defn- resolve-entity-id
|
||||
[config repo lookup]
|
||||
(p/let [entity (pull-entity config repo [:db/id] lookup)]
|
||||
(if-let [id (:db/id entity)]
|
||||
id
|
||||
(throw (ex-info "entity not found" {:code :entity-not-found :lookup lookup})))))
|
||||
|
||||
(defn- resolve-page-id
|
||||
[config repo value]
|
||||
(cond
|
||||
(number? value) (p/resolved value)
|
||||
(uuid? value) (resolve-entity-id config repo [:block/uuid value])
|
||||
(and (string? value) (common-util/uuid-string? (string/trim value)))
|
||||
(resolve-entity-id config repo [:block/uuid (uuid (string/trim value))])
|
||||
(string? value)
|
||||
(p/let [page (ensure-page! config repo value)]
|
||||
(or (:db/id page)
|
||||
(throw (ex-info "page not found" {:code :page-not-found :value value}))))
|
||||
:else
|
||||
(p/rejected (ex-info "page must be a name or uuid" {:code :invalid-page :value value}))))
|
||||
|
||||
(defn- resolve-class-id
|
||||
[config repo value]
|
||||
(p/let [entity (resolve-tag-entity config repo value)]
|
||||
(:db/id entity)))
|
||||
|
||||
(defn- resolve-property-id
|
||||
[config repo value]
|
||||
(let [key (normalize-property-key value)]
|
||||
(when-not key
|
||||
(throw (ex-info "property must be a keyword" {:code :invalid-property :value value})))
|
||||
(resolve-entity-id config repo [:db/ident key])))
|
||||
|
||||
(defn- resolve-node-id
|
||||
[config repo value]
|
||||
(cond
|
||||
(number? value) (p/resolved value)
|
||||
(uuid? value) (resolve-entity-id config repo [:block/uuid value])
|
||||
(and (string? value) (common-util/uuid-string? (string/trim value)))
|
||||
(resolve-entity-id config repo [:block/uuid (uuid (string/trim value))])
|
||||
(string? value)
|
||||
(resolve-page-id config repo value)
|
||||
:else
|
||||
(p/rejected (ex-info "node must be a uuid or page name" {:code :invalid-node :value value}))))
|
||||
|
||||
(defn- resolve-date-page-id
|
||||
[config repo value]
|
||||
(when-not (string? value)
|
||||
(throw (ex-info "date must be a string" {:code :invalid-date :value value})))
|
||||
(p/let [journal (pull-entity config repo [:logseq.property.journal/title-format] :logseq.class/Journal)
|
||||
formatter (or (:logseq.property.journal/title-format journal) "MMM do, yyyy")
|
||||
formatters (date-time-util/safe-journal-title-formatters formatter)
|
||||
journal-day (date-time-util/journal-title->int value formatters)
|
||||
title (or (when journal-day
|
||||
(date-time-util/int->journal-title journal-day formatter))
|
||||
value)
|
||||
page (ensure-page! config repo title)]
|
||||
(if-let [id (:db/id page)]
|
||||
id
|
||||
(throw (ex-info "journal page not found" {:code :page-not-found :value value})))))
|
||||
|
||||
(defn- resolve-property-value
|
||||
[config repo property value]
|
||||
(let [type (get-in property [:schema :type] :default)]
|
||||
(case type
|
||||
:page (resolve-page-id config repo value)
|
||||
:class (resolve-class-id config repo value)
|
||||
:property (resolve-property-id config repo value)
|
||||
:entity (resolve-entity-id config repo (cond
|
||||
(number? value) value
|
||||
(uuid? value) [:block/uuid value]
|
||||
(and (string? value) (common-util/uuid-string? (string/trim value)))
|
||||
[:block/uuid (uuid (string/trim value))]
|
||||
:else value))
|
||||
:node (resolve-node-id config repo value)
|
||||
:date (resolve-date-page-id config repo value)
|
||||
(p/resolved value))))
|
||||
|
||||
(defn- resolve-properties
|
||||
[config repo properties]
|
||||
(if-not (seq properties)
|
||||
(p/resolved nil)
|
||||
(p/let [entries (p/all
|
||||
(map (fn [[k v]]
|
||||
(let [property (get db-property/built-in-properties k)
|
||||
many? (= :many (get-in property [:schema :cardinality]))
|
||||
values (if many?
|
||||
(if (and (coll? v) (not (string? v))) v [v])
|
||||
[v])]
|
||||
(p/let [resolved (p/all (map #(resolve-property-value config repo property %) values))
|
||||
final-value (if many? (vec resolved) (first resolved))]
|
||||
[k final-value])))
|
||||
properties))]
|
||||
(into {} entries))))
|
||||
|
||||
(defn- resolve-add-target
|
||||
[config {:keys [repo target-id target-uuid target-page-name]}]
|
||||
(cond
|
||||
@@ -185,13 +563,24 @@
|
||||
:message "repo is required for add"}}
|
||||
(let [blocks-result (read-blocks options args)
|
||||
status-text (some-> (:status options) string/trim)
|
||||
status (when (seq status-text) (normalize-status status-text))]
|
||||
status (when (seq status-text) (normalize-status status-text))
|
||||
tags-result (parse-tags-option (:tags options))
|
||||
properties-result (parse-properties-option (:properties options))
|
||||
tags (:value tags-result)
|
||||
properties (:value properties-result)
|
||||
ensure-uuids? (or status (seq tags) (seq properties))]
|
||||
(cond
|
||||
(and (seq status-text) (nil? status))
|
||||
{:ok? false
|
||||
:error {:code :invalid-options
|
||||
:message (str "invalid status: " status-text)}}
|
||||
|
||||
(not (:ok? tags-result))
|
||||
tags-result
|
||||
|
||||
(not (:ok? properties-result))
|
||||
properties-result
|
||||
|
||||
:else
|
||||
(if-not (:ok? blocks-result)
|
||||
blocks-result
|
||||
@@ -199,7 +588,7 @@
|
||||
(if-not (:ok? vector-result)
|
||||
vector-result
|
||||
(let [blocks (cond-> (:value vector-result)
|
||||
status
|
||||
ensure-uuids?
|
||||
ensure-block-uuids)]
|
||||
{:ok? true
|
||||
:action {:type :add-block
|
||||
@@ -210,6 +599,8 @@
|
||||
:target-page-name (some-> (:target-page-name options) string/trim)
|
||||
:pos (or (some-> (:pos options) string/trim string/lower-case) "last-child")
|
||||
:status status
|
||||
:tags tags
|
||||
:properties properties
|
||||
:blocks blocks}}))))))))
|
||||
|
||||
(defn build-add-page-action
|
||||
@@ -220,11 +611,23 @@
|
||||
:message "repo is required for add"}}
|
||||
(let [page (some-> (:page options) string/trim)]
|
||||
(if (seq page)
|
||||
{:ok? true
|
||||
:action {:type :add-page
|
||||
:repo repo
|
||||
:graph (core/repo->graph repo)
|
||||
:page page}}
|
||||
(let [tags-result (parse-tags-option (:tags options))
|
||||
properties-result (parse-properties-option (:properties options))]
|
||||
(cond
|
||||
(not (:ok? tags-result))
|
||||
tags-result
|
||||
|
||||
(not (:ok? properties-result))
|
||||
properties-result
|
||||
|
||||
:else
|
||||
{:ok? true
|
||||
:action {:type :add-page
|
||||
:repo repo
|
||||
:graph (core/repo->graph repo)
|
||||
:page page
|
||||
:tags (:value tags-result)
|
||||
:properties (:value properties-result)}}))
|
||||
{:ok? false
|
||||
:error {:code :missing-page-name
|
||||
:message "page name is required"}}))))
|
||||
@@ -234,35 +637,74 @@
|
||||
(-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
|
||||
target-id (resolve-add-target cfg action)
|
||||
status (:status action)
|
||||
tags (resolve-tags cfg (:repo action) (:tags action))
|
||||
properties (resolve-properties cfg (:repo action) (:properties action))
|
||||
pos (:pos action)
|
||||
keep-uuid? (or status (seq tags) (seq properties))
|
||||
opts (case pos
|
||||
"last-child" {:sibling? false :bottom? true}
|
||||
"sibling" {:sibling? true}
|
||||
{:sibling? false})
|
||||
opts (cond-> opts
|
||||
status
|
||||
keep-uuid?
|
||||
(assoc :keep-uuid? true))
|
||||
ops [[:insert-blocks [(:blocks action)
|
||||
target-id
|
||||
(assoc opts :outliner-op :insert-blocks)]]]
|
||||
_ (transport/invoke cfg :thread-api/apply-outliner-ops false [(:repo action) ops {}])
|
||||
_ (when status
|
||||
(let [block-ids (->> (:blocks action)
|
||||
(map :block/uuid)
|
||||
(remove nil?)
|
||||
vec)]
|
||||
(when (seq block-ids)
|
||||
(transport/invoke cfg :thread-api/apply-outliner-ops false
|
||||
[(:repo action)
|
||||
[[:batch-set-property [block-ids :logseq.property/status status {}]]]
|
||||
{}]))))]
|
||||
block-ids (->> (:blocks action)
|
||||
(map :block/uuid)
|
||||
(remove nil?)
|
||||
vec)
|
||||
tag-ids (when (seq tags)
|
||||
(->> tags (map :db/id) (remove nil?) vec))
|
||||
_ (when (and status (seq block-ids))
|
||||
(transport/invoke cfg :thread-api/apply-outliner-ops false
|
||||
[(:repo action)
|
||||
[[:batch-set-property [block-ids :logseq.property/status status {}]]]
|
||||
{}]))
|
||||
_ (when (and (seq tag-ids) (seq block-ids))
|
||||
(p/all
|
||||
(map (fn [tag-id]
|
||||
(transport/invoke cfg :thread-api/apply-outliner-ops false
|
||||
[(:repo action)
|
||||
[[:batch-set-property [block-ids :block/tags tag-id {}]]]
|
||||
{}]))
|
||||
tag-ids)))
|
||||
_ (when (and (seq properties) (seq block-ids))
|
||||
(p/all
|
||||
(map (fn [[k v]]
|
||||
(transport/invoke cfg :thread-api/apply-outliner-ops false
|
||||
[(:repo action)
|
||||
[[:batch-set-property [block-ids k v {}]]]
|
||||
{}]))
|
||||
properties)))]
|
||||
{:status :ok
|
||||
:data {:result nil}})))
|
||||
|
||||
(defn execute-add-page
|
||||
[action config]
|
||||
(-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
|
||||
ops [[:create-page [(:page action) {}]]]
|
||||
result (transport/invoke cfg :thread-api/apply-outliner-ops false [(:repo action) ops {}])]
|
||||
tags (resolve-tags cfg (:repo action) (:tags action))
|
||||
tag-ids (when (seq tags)
|
||||
(->> tags (map :db/id) (remove nil?) vec))
|
||||
properties (resolve-properties cfg (:repo action) (:properties action))
|
||||
options (cond-> {}
|
||||
(seq properties) (assoc :properties properties))
|
||||
ops [[:create-page [(:page action) options]]]
|
||||
result (transport/invoke cfg :thread-api/apply-outliner-ops false [(:repo action) ops {}])
|
||||
_ (when (seq tag-ids)
|
||||
(p/let [page-name (common-util/page-name-sanity-lc (:page action))
|
||||
page (pull-entity cfg (:repo action) [:db/id :block/uuid] [:block/name page-name])
|
||||
page-uuid (:block/uuid page)]
|
||||
(when-not page-uuid
|
||||
(throw (ex-info "page not found" {:code :page-not-found :page (:page action)})))
|
||||
(p/all
|
||||
(map (fn [tag-id]
|
||||
(transport/invoke cfg :thread-api/apply-outliner-ops false
|
||||
[(:repo action)
|
||||
[[:batch-set-property [[page-uuid] :block/tags tag-id {}]]]
|
||||
{}]))
|
||||
tag-ids))))]
|
||||
{:status :ok
|
||||
:data {:result result}})))
|
||||
|
||||
@@ -423,6 +423,16 @@
|
||||
(is (= "abc" (get-in result [:options :target-uuid])))
|
||||
(is (= "first-child" (get-in result [:options :pos])))))
|
||||
|
||||
(testing "add block parses with tags and properties"
|
||||
(let [result (commands/parse-args ["add" "block"
|
||||
"--content" "hello"
|
||||
"--tags" "[\"TagA\" \"TagB\"]"
|
||||
"--properties" "{:logseq.property/publishing-public? true}"])]
|
||||
(is (true? (:ok? result)))
|
||||
(is (= :add-block (:command result)))
|
||||
(is (= "[\"TagA\" \"TagB\"]" (get-in result [:options :tags])))
|
||||
(is (= "{:logseq.property/publishing-public? true}" (get-in result [:options :properties])))))
|
||||
|
||||
(testing "add block rejects invalid pos"
|
||||
(let [result (commands/parse-args ["add" "block"
|
||||
"--content" "hello"
|
||||
@@ -430,6 +440,20 @@
|
||||
(is (false? (:ok? result)))
|
||||
(is (= :invalid-options (get-in result [:error :code])))))
|
||||
|
||||
(testing "add block rejects tags with blocks payload"
|
||||
(let [result (commands/parse-args ["add" "block"
|
||||
"--blocks" "[]"
|
||||
"--tags" "[\"TagA\"]"])]
|
||||
(is (false? (:ok? result)))
|
||||
(is (= :invalid-options (get-in result [:error :code])))))
|
||||
|
||||
(testing "add block rejects properties with blocks-file payload"
|
||||
(let [result (commands/parse-args ["add" "block"
|
||||
"--blocks-file" "/tmp/blocks.edn"
|
||||
"--properties" "{:logseq.property/publishing-public? true}"])]
|
||||
(is (false? (:ok? result)))
|
||||
(is (= :invalid-options (get-in result [:error :code])))))
|
||||
|
||||
(testing "add page requires page name"
|
||||
(let [result (commands/parse-args ["add" "page"])]
|
||||
(is (false? (:ok? result)))
|
||||
@@ -441,6 +465,16 @@
|
||||
(is (= :add-page (:command result)))
|
||||
(is (= "Home" (get-in result [:options :page])))))
|
||||
|
||||
(testing "add page parses with tags and properties"
|
||||
(let [result (commands/parse-args ["add" "page"
|
||||
"--page" "Home"
|
||||
"--tags" "[\"TagA\"]"
|
||||
"--properties" "{:logseq.property/publishing-public? true}"])]
|
||||
(is (true? (:ok? result)))
|
||||
(is (= :add-page (:command result)))
|
||||
(is (= "[\"TagA\"]" (get-in result [:options :tags])))
|
||||
(is (= "{:logseq.property/publishing-public? true}" (get-in result [:options :properties])))))
|
||||
|
||||
(testing "remove requires target"
|
||||
(let [result (commands/parse-args ["remove"])]
|
||||
(is (false? (:ok? result)))
|
||||
@@ -732,6 +766,31 @@
|
||||
(is (= :show (get-in result [:action :type])))
|
||||
(is (= [1 2] (get-in result [:action :ids]))))))
|
||||
|
||||
(deftest test-build-action-add-validates-properties
|
||||
(testing "add block rejects unknown property"
|
||||
(let [parsed (commands/parse-args ["add" "block"
|
||||
"--content" "hello"
|
||||
"--properties" "{:not/a 1}"])
|
||||
result (commands/build-action parsed {:repo "demo"})]
|
||||
(is (false? (:ok? result)))
|
||||
(is (= :invalid-options (get-in result [:error :code])))))
|
||||
|
||||
(testing "add block rejects non-public built-in property"
|
||||
(let [parsed (commands/parse-args ["add" "block"
|
||||
"--content" "hello"
|
||||
"--properties" "{:logseq.property/heading 1}"])
|
||||
result (commands/build-action parsed {:repo "demo"})]
|
||||
(is (false? (:ok? result)))
|
||||
(is (= :invalid-options (get-in result [:error :code])))))
|
||||
|
||||
(testing "add block rejects invalid checkbox value"
|
||||
(let [parsed (commands/parse-args ["add" "block"
|
||||
"--content" "hello"
|
||||
"--properties" "{:logseq.property/publishing-public? \"nope\"}"])
|
||||
result (commands/build-action parsed {:repo "demo"})]
|
||||
(is (false? (:ok? result)))
|
||||
(is (= :invalid-options (get-in result [:error :code]))))))
|
||||
|
||||
(deftest test-build-action-move
|
||||
(testing "move requires source selector"
|
||||
(let [parsed {:ok? true :command :move-block :options {:target-id 2}}
|
||||
|
||||
@@ -127,6 +127,106 @@
|
||||
(is false (str "unexpected error: " e))
|
||||
(done)))))))
|
||||
|
||||
(deftest test-cli-add-tags-and-properties
|
||||
(async done
|
||||
(let [data-dir (node-helper/create-tmp-dir "db-worker-tags")]
|
||||
(-> (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" "tags-graph"] data-dir cfg-path)
|
||||
_ (run-cli ["--repo" "tags-graph" "add" "page" "--page" "Home"] data-dir cfg-path)
|
||||
add-page-result (run-cli ["--repo" "tags-graph"
|
||||
"add" "page"
|
||||
"--page" "TaggedPage"
|
||||
"--tags" "[\"Quote\"]"
|
||||
"--properties" "{:logseq.property/publishing-public? true}"]
|
||||
data-dir cfg-path)
|
||||
add-page-payload (parse-json-output add-page-result)
|
||||
add-block-result (run-cli ["--repo" "tags-graph"
|
||||
"add" "block"
|
||||
"--target-page-name" "Home"
|
||||
"--content" "Tagged block"
|
||||
"--tags" "[\"Quote\"]"
|
||||
"--properties" "{:logseq.property/deadline \"2026-01-25T12:00:00Z\"}"]
|
||||
data-dir cfg-path)
|
||||
add-block-payload (parse-json-output add-block-result)
|
||||
_ (p/delay 100)
|
||||
query-block-tags-result (run-cli ["--repo" "tags-graph"
|
||||
"query"
|
||||
"--query" "[:find ?tag :in $ ?title :where [?b :block/title ?title] [?b :block/tags ?t] [?t :block/title ?tag]]"
|
||||
"--inputs" "[\"Tagged block\"]"]
|
||||
data-dir cfg-path)
|
||||
query-block-tags-payload (parse-json-output query-block-tags-result)
|
||||
block-tag-names (->> (get-in query-block-tags-payload [:data :result])
|
||||
(map first)
|
||||
set)
|
||||
query-page-tags-result (run-cli ["--repo" "tags-graph"
|
||||
"query"
|
||||
"--query" "[:find ?tag :in $ ?title :where [?p :block/title ?title] [?p :block/tags ?t] [?t :block/title ?tag]]"
|
||||
"--inputs" "[\"TaggedPage\"]"]
|
||||
data-dir cfg-path)
|
||||
query-page-tags-payload (parse-json-output query-page-tags-result)
|
||||
page-tag-names (->> (get-in query-page-tags-payload [:data :result])
|
||||
(map first)
|
||||
set)
|
||||
query-page-result (run-cli ["--repo" "tags-graph"
|
||||
"query"
|
||||
"--query" "[:find ?value :in $ ?title :where [?p :block/title ?title] [?p :logseq.property/publishing-public? ?value]]"
|
||||
"--inputs" "[\"TaggedPage\"]"]
|
||||
data-dir cfg-path)
|
||||
query-page-payload (parse-json-output query-page-result)
|
||||
page-value (first (first (get-in query-page-payload [:data :result])))
|
||||
query-block-result (run-cli ["--repo" "tags-graph"
|
||||
"query"
|
||||
"--query" "[:find ?value :in $ ?title :where [?b :block/title ?title] [?b :logseq.property/deadline ?value]]"
|
||||
"--inputs" "[\"Tagged block\"]"]
|
||||
data-dir cfg-path)
|
||||
query-block-payload (parse-json-output query-block-result)
|
||||
block-deadline (first (first (get-in query-block-payload [:data :result])))
|
||||
stop-result (run-cli ["server" "stop" "--repo" "tags-graph"] data-dir cfg-path)
|
||||
stop-payload (parse-json-output stop-result)]
|
||||
(is (= 0 (:exit-code add-page-result)))
|
||||
(is (= "ok" (:status add-page-payload)))
|
||||
(is (= 0 (:exit-code add-block-result)))
|
||||
(is (= "ok" (:status add-block-payload)))
|
||||
(is (contains? block-tag-names "Quote"))
|
||||
(is (contains? page-tag-names "Quote"))
|
||||
(is (true? page-value))
|
||||
(is (number? block-deadline))
|
||||
(is (= "ok" (:status stop-payload)))
|
||||
(done))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))
|
||||
(done)))))))
|
||||
|
||||
(deftest test-cli-add-tags-rejects-missing-tag
|
||||
(async done
|
||||
(let [data-dir (node-helper/create-tmp-dir "db-worker-tags-missing")]
|
||||
(-> (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" "tags-missing-graph"] data-dir cfg-path)
|
||||
add-block-result (run-cli ["--repo" "tags-missing-graph"
|
||||
"add" "block"
|
||||
"--target-page-name" "Home"
|
||||
"--content" "Block with missing tag"
|
||||
"--tags" "[\"MissingTag\"]"]
|
||||
data-dir cfg-path)
|
||||
add-block-payload (parse-json-output add-block-result)
|
||||
list-tag-result (run-cli ["--repo" "tags-missing-graph" "list" "tag"] data-dir cfg-path)
|
||||
list-tag-payload (parse-json-output list-tag-result)
|
||||
tag-names (->> (get-in list-tag-payload [:data :items])
|
||||
(map #(or (:block/title %) (:block/name %)))
|
||||
set)
|
||||
stop-result (run-cli ["server" "stop" "--repo" "tags-missing-graph"] data-dir cfg-path)
|
||||
stop-payload (parse-json-output stop-result)]
|
||||
(is (= 1 (:exit-code add-block-result)))
|
||||
(is (= "error" (:status add-block-payload)))
|
||||
(is (not (contains? tag-names "MissingTag")))
|
||||
(is (= "ok" (:status stop-payload)))
|
||||
(done))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))
|
||||
(done)))))))
|
||||
|
||||
(deftest test-cli-query
|
||||
(async done
|
||||
(let [data-dir (node-helper/create-tmp-dir "db-worker-query")
|
||||
|
||||
Reference in New Issue
Block a user