018-logseq-cli-add-tags-builtin-properties.md

This commit is contained in:
rcmerci
2026-01-26 23:46:11 +08:00
parent 0164cce559
commit 2b614ab8e7
4 changed files with 819 additions and 22 deletions

View 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.
---

View File

@@ -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}})))

View File

@@ -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}}

View File

@@ -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")