Files
logseq/src/main/logseq/cli/command/add.cljs

1214 lines
51 KiB
Clojure

(ns logseq.cli.command.add
"Add-related CLI commands. Used by upsert to provide create mode"
(:require ["fs" :as fs]
[cljs-time.coerce :as tc]
[cljs-time.core :as t]
[cljs.reader :as reader]
[clojure.string :as string]
[logseq.cli.command.core :as core]
[logseq.cli.server :as cli-server]
[logseq.cli.transport :as transport]
[logseq.common.util :as common-util]
[logseq.common.util.date-time :as date-time-util]
[logseq.common.util.page-ref :as page-ref]
[logseq.common.uuid :as common-uuid]
[logseq.db :as ldb]
[logseq.db.frontend.content :as db-content]
[logseq.db.frontend.property :as db-property]
[logseq.db.frontend.property.type :as db-property-type]
[promesa.core :as p]))
(defn- today-page-title
[config repo]
(p/let [journal (transport/invoke config :thread-api/pull
[repo [:logseq.property.journal/title-format] :logseq.class/Journal])
formatter (or (:logseq.property.journal/title-format journal) "MMM do, yyyy")
now (-> (js/Date.)
tc/from-date
t/to-default-time-zone)]
(date-time-util/format now formatter)))
(defn find-pages-by-name
"Query all live (non-recycled) pages matching a given name. Returns a vector
of entity maps. Because :block/name is not unique (a tag, property and page
can share the same name), callers should check for ambiguity."
[config repo page-name selector]
(p/let [results (transport/invoke config :thread-api/q
[repo
[{:find [[(list 'pull '?e selector) '...]]
:in '[$ ?name]
:where '[[?e :block/name ?name]]}
(common-util/page-name-sanity-lc page-name)]])]
(vec (remove ldb/recycled? (or results [])))))
(defn throw-ambiguous-page-error!
"Throws when multiple pages match the same name. Callers should present the
candidates so the user can rerun with --id."
[page-name matches]
(let [candidates (->> matches
(map (fn [item]
{:id (:db/id item)
:name (or (:block/title item) (:block/name item))}))
(filter :id)
vec)]
(throw (ex-info (str "multiple pages match name: " page-name "; rerun with --id")
{:code :ambiguous-page-name
:candidates candidates}))))
(defn- ensure-page!
"Ensure a page only if it is unique. Otherwise returns an ambiugous error to let user choose the specific
intended page"
[config repo page-name]
(p/let [live (find-pages-by-name config repo page-name
[:db/id :block/uuid :block/name :block/title])
_ (when (> (count live) 1)
(throw-ambiguous-page-error! page-name live))
page (first live)]
(if (:db/id page)
page
(let [page-name-lc (common-util/page-name-sanity-lc page-name)]
(p/let [_ (transport/invoke config :thread-api/apply-outliner-ops
[repo [[:create-page [page-name {}]]] {}])]
(transport/invoke config :thread-api/pull
[repo [:db/id :block/uuid :block/name :block/title] [:block/name page-name-lc]]))))))
(defn pull-tag-by-name
"Look up a tag by name, constrained to entities tagged with :logseq.class/Tag."
[config repo tag-name selector]
(p/let [result (transport/invoke config :thread-api/q
[repo
[{:find [[(list 'pull '?e selector) '...]]
:in '[$ ?name]
:where '[[?e :block/name ?name]
[?e :block/tags ?t]
[?t :db/ident :logseq.class/Tag]]}
(common-util/page-name-sanity-lc tag-name)]])]
(first result)))
(defn pull-property-by-name
"Look up a property by name, constrained to entities tagged with :logseq.class/Property."
[config repo property-name selector]
(p/let [result (transport/invoke config :thread-api/q
[repo
[{:find [[(list 'pull '?e selector) '...]]
:in '[$ ?name]
:where '[[?e :block/name ?name]
[?e :block/tags ?t]
[?t :db/ident :logseq.class/Property]]}
(common-util/page-name-sanity-lc property-name)]])]
(first result)))
(def ^:private add-positions
#{"first-child" "last-child" "sibling"})
(def ^:private status-aliases
{"todo" :logseq.property/status.todo
"doing" :logseq.property/status.doing
"done" :logseq.property/status.done
"now" :logseq.property/status.doing
"later" :logseq.property/status.todo
"wait" :logseq.property/status.backlog
"waiting" :logseq.property/status.backlog
"backlog" :logseq.property/status.backlog
"canceled" :logseq.property/status.canceled
"cancelled" :logseq.property/status.canceled
"in-review" :logseq.property/status.in-review
"in_review" :logseq.property/status.in-review
"inreview" :logseq.property/status.in-review
"in-progress" :logseq.property/status.doing
"in progress" :logseq.property/status.doing
"inprogress" :logseq.property/status.doing})
(defn normalize-status
[value]
(let [text (some-> value string/trim)
parsed (when (and (seq text) (string/starts-with? text ":"))
(common-util/safe-read-string {:log-error? false} text))
normalized (cond
(qualified-keyword? parsed)
parsed
(keyword? parsed)
(get status-aliases (name parsed))
(seq text)
(get status-aliases (string/lower-case text))
:else nil)]
normalized))
(defn- ensure-block-uuids
[blocks]
(mapv (fn ensure-block-uuid [block]
(let [current (:block/uuid block)
block (cond
(some? current)
(update block :block/uuid (fn [value]
(if (and (string? value) (common-util/uuid-string? value))
(uuid value)
value)))
:else
(assoc block :block/uuid (common-uuid/gen-uuid)))]
(if (seq (:block/children block))
(update block :block/children ensure-block-uuids)
block)))
blocks))
(defn- normalize-created-ids
[ids]
(->> ids
(remove nil?)
distinct
vec))
(defn- normalized-uuid
[value]
(cond
(uuid? value) value
(and (string? value) (common-util/uuid-string? (string/trim value)))
(uuid (string/trim value))
:else nil))
(defn- collect-created-block-uuids
[blocks]
(letfn [(walk [acc block]
(let [block-uuid (normalized-uuid (:block/uuid block))
acc (if block-uuid
(conj acc block-uuid)
acc)
children (:block/children block)]
(if (seq children)
(reduce walk acc children)
acc)))]
(->> (reduce walk [] blocks)
distinct
vec)))
(defn- flatten-block-tree
[blocks]
(letfn [(walk [parent-uuid block]
(let [children (:block/children block)
block (cond-> (dissoc block :block/children)
parent-uuid
(assoc :block/parent [:block/uuid parent-uuid]))
block-uuid (normalized-uuid (:block/uuid block))]
(into [block]
(mapcat #(walk block-uuid %) children))))]
(vec (mapcat #(walk nil %) blocks))))
(defn- created-ids-in-order
[ordered-uuids entities entity-kind]
(let [id-by-uuid (reduce (fn [acc {:keys [db/id block/uuid]}]
(if-let [entity-uuid (normalized-uuid uuid)]
(if (some? id)
(assoc acc entity-uuid id)
acc)
acc))
{}
entities)
missing-uuids (->> ordered-uuids
(remove #(contains? id-by-uuid %))
vec)]
(when (seq missing-uuids)
(throw (ex-info "unable to resolve created ids"
{:code :add-id-resolution-failed
:entity-kind entity-kind
:missing-uuids missing-uuids})))
(normalize-created-ids (map #(get id-by-uuid %) ordered-uuids))))
(defn- resolve-created-block-ids
[config repo blocks insert-result]
(let [ordered-uuids (or (seq (collect-created-block-uuids (:tx-data insert-result)))
(seq (collect-created-block-uuids blocks))
(seq (collect-created-block-uuids (:blocks insert-result))))]
(if-not (seq ordered-uuids)
(p/rejected (ex-info "unable to resolve created block ids"
{:code :add-id-resolution-failed
:entity-kind :block
:reason :missing-created-uuids}))
(p/let [entities (p/all
(map (fn [block-uuid]
(transport/invoke config :thread-api/pull
[repo [:db/id :block/uuid] [:block/uuid block-uuid]]))
ordered-uuids))]
(created-ids-in-order ordered-uuids entities :block)))))
(defn- extract-page-refs
[title]
(when (string? title)
(->> (re-seq page-ref/page-ref-re title)
(map second)
(remove string/blank?))))
(defn- collect-page-refs
[blocks]
(->> blocks
(mapcat (fn walk [block]
(let [refs (extract-page-refs (:block/title block))
children (:block/children block)]
(if (seq children)
(concat refs (mapcat walk children))
refs))))
(remove string/blank?)
vec))
(defn- integer-string?
[s]
(boolean (re-matches #"-?\d+" s)))
(defn- partition-ref-values
[refs]
(reduce
(fn [acc ref-value]
(let [value (string/trim ref-value)]
(cond
(string/blank? value)
acc
(common-util/uuid-string? value)
(update acc :uuid-refs conj value)
(integer-string? value)
(update acc :id-refs conj value)
:else
(update acc :page-refs conj value))))
{:uuid-refs [] :page-refs [] :id-refs []}
refs))
(defn- resolve-page-ref-entities
[config repo page-refs]
(if (seq page-refs)
(let [unique (reduce (fn [acc ref-value]
(let [value (string/trim ref-value)]
(if (string/blank? value)
acc
(assoc acc (common-util/page-name-sanity-lc value) value))))
{}
page-refs)]
(p/let [resolved (p/all
(map (fn [[_ page-name]]
(p/let [page (ensure-page! config repo page-name)
page-uuid (:block/uuid page)]
(when-not page-uuid
(throw (ex-info "page not found"
{:code :page-not-found
:page page-name})))
{:block/uuid page-uuid
:block/title (or (:block/title page) page-name)}))
unique))]
(vec resolved)))
(p/resolved nil)))
(defn- ensure-block-refs-exist!
[config repo uuid-refs]
(when (seq uuid-refs)
(p/all
(map (fn [uuid-ref]
(p/let [entity (transport/invoke config :thread-api/pull
[repo [:db/id :block/uuid] [:block/uuid (uuid uuid-ref)]])]
(when-not (:db/id entity)
(throw (ex-info (str "block ref not found: " uuid-ref)
{:code :block-ref-not-found
:uuid uuid-ref})))))
(distinct uuid-refs)))))
(defn- resolve-id-ref-entities
"Resolve integer id refs (db/id values) to entity maps with :block/uuid and
:block/title so they can be normalized like page-name refs."
[config repo id-refs]
(if (seq id-refs)
(p/let [entities (p/all
(map (fn [id-str]
(let [id (parse-long id-str)]
(p/let [entity (transport/invoke config :thread-api/pull
[repo [:db/id :block/uuid :block/title] id])]
(when-not (:db/id entity)
(throw (ex-info (str "id ref not found: " id-str)
{:code :id-ref-not-found
:id id-str})))
{:block/uuid (:block/uuid entity)
:block/title id-str})))
(distinct id-refs)))]
(vec entities))
(p/resolved nil)))
(defn- normalize-block-title-refs
[blocks refs]
(mapv (fn update-block [block]
(let [block' (if (string? (:block/title block))
(update block :block/title
#(db-content/title-ref->id-ref % refs :replace-tag? false))
block)]
(if (seq (:block/children block'))
(update block' :block/children #(normalize-block-title-refs % refs))
block')))
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- normalize-tag-value
[value]
(cond
(uuid? value) value
(number? value) value
(and (string? value) (common-util/uuid-string? (string/trim value)))
(uuid (string/trim value))
(keyword? value) value
(string? value) (let [text (-> value string/trim (string/replace #"^#+" ""))]
(cond
(string/blank? text) nil
(common-util/valid-edn-keyword? text)
(common-util/safe-read-string {:log-error? false} text)
:else 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, uuids, or ids")
{:ok? true :value tags}))))))
(defn parse-tags-vector-option
[value]
(parse-tags-option value))
(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))
(def ^:private built-in-properties-by-title
(into {}
(keep (fn [[ident {:keys [title]}]]
(when (string? title)
[(common-util/page-name-sanity-lc title) ident])))
db-property/built-in-properties))
(defn- property-title->ident
[value]
(when (string? value)
(let [text (string/trim value)]
(when (seq text)
(get built-in-properties-by-title (common-util/page-name-sanity-lc text))))))
(defn normalize-property-key-input
[value]
(cond
(keyword? value) {:type :ident :value value}
(number? value) {:type :id :value value}
(string? value)
(let [text (string/trim value)]
(cond
(string/blank? text) nil
(common-util/valid-edn-keyword? text)
(let [parsed (common-util/safe-read-string {:log-error? false} text)]
(when (keyword? parsed)
{:type :ident :value parsed}))
:else
(if-let [ident (property-title->ident text)]
{:type :ident :value ident}
{:type :ident :value (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"})
(= type :default)
(if (or (string? value) (keyword? value))
{:ok? true :value value}
{:ok? false :message "default property expects a string value or keyword"})
(contains? #{:entity :page :class :property :node :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]
(parse-properties-option value {:allow-non-built-in? false}))
([value {:keys [allow-non-built-in?]
:or {allow-non-built-in? false}}]
(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 [prop-entries (seq parsed)
acc {}]
(if (empty? prop-entries)
{:ok? true :value acc}
(let [[k v] (first prop-entries)
key-result (normalize-property-key-input k)]
(if-not key-result
(invalid-options-result (str "invalid property key: " k))
(let [{:keys [type value]} key-result
key-ident value]
(if (= type :id)
(recur (rest prop-entries) (assoc acc key-ident v))
(let [property (get db-property/built-in-properties key-ident)]
(cond
(nil? property)
(if allow-non-built-in?
(recur (rest prop-entries) (assoc acc key-ident v))
(invalid-options-result (str "unknown built-in property: " key-ident)))
(not (property-public? property))
(invalid-options-result (str "property is not public: " key-ident))
:else
(let [{:keys [ok? value message]} (normalize-property-values property v)
normalized-value value]
(if-not ok?
(invalid-options-result (str "invalid value for " key-ident ": " message))
(recur (rest prop-entries) (assoc acc key-ident normalized-value)))))))))))))))))
(defn parse-properties-vector-option
([value]
(parse-properties-vector-option value {:allow-non-built-in? false}))
([value {:keys [allow-non-built-in?]
:or {allow-non-built-in? false}}]
(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 vector")
(not (vector? parsed))
(invalid-options-result "properties must be a vector")
(empty? parsed)
(invalid-options-result "properties must be a non-empty vector")
:else
(loop [prop-entries (seq parsed)
acc []]
(if (empty? prop-entries)
{:ok? true :value acc}
(let [entry (first prop-entries)
key-result (normalize-property-key-input entry)]
(if-not key-result
(invalid-options-result (str "invalid property key: " entry))
(let [{:keys [type value]} key-result]
(if (= type :id)
(recur (rest prop-entries) (conj acc value))
(let [property (get db-property/built-in-properties value)]
(cond
(nil? property)
(if allow-non-built-in?
(recur (rest prop-entries) (conj acc value))
(invalid-options-result (str "unknown built-in property: " value)))
(not (property-public? property))
(invalid-options-result (str "property is not public: " value))
:else
(recur (rest prop-entries) (conj acc 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])
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))
(> (count target-selectors) 1)
"only one of --target-id, --target-uuid, or --target-page-name is allowed"
(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"
(or (:remove-tags opts) (:remove-properties opts))
"--remove-tags and --remove-properties are only for update mode"
:else
nil)))
(defn- pull-entity
[config repo selector lookup]
(transport/invoke config :thread-api/pull [repo selector lookup]))
(defn- tag-lookup-ref
[tag]
(cond
(number? tag) tag
(uuid? tag) [:block/uuid tag]
(and (string? tag) (common-util/uuid-string? (string/trim tag))) [:block/uuid (uuid (string/trim tag))]
(keyword? tag) [:db/ident tag]
:else nil))
(defn- resolve-tag-entity
[config repo tag]
(let [tag-selector [:db/id :block/name :block/title :block/uuid
{:block/tags [:db/ident]}
:logseq.property/public? :logseq.property/built-in?]]
(p/let [entity (cond
(and (string? tag) (common-util/uuid-string? (string/trim tag)))
(pull-entity config repo tag-selector [:block/uuid (uuid (string/trim tag))])
(string? tag)
(pull-tag-by-name config repo tag tag-selector)
:else
(let [lookup (or (tag-lookup-ref tag)
(throw (ex-info "invalid tag value" {:code :invalid-tag :tag tag})))]
(pull-entity config repo tag-selector lookup)))]
(cond
(nil? (:db/id entity))
(throw (ex-info (str "tag not found: " (pr-str tag)) {:code :tag-not-found :tag tag}))
(not (some #(= :logseq.class/Tag (:db/ident %)) (:block/tags entity)))
(throw (ex-info (str "This is not a tag: " (pr-str tag)) {:code :not-a-tag :tag tag}))
(false? (:logseq.property/public? entity))
(throw (ex-info (str "tag is not public: " (pr-str tag)) {: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 number, 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 property value 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)
_ (when-not journal-day
(throw (ex-info (str "invalid date property value: " (pr-str value))
{:code :invalid-date :value value})))
title (date-time-util/int->journal-title journal-day formatter)
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))))
(def ^:private property-entity-selector
[:db/id :db/ident :block/name :block/title
:logseq.property/type :db/cardinality :logseq.property/public?])
(defn- property-entity?
[entity]
(some? (:logseq.property/type entity)))
(defn- property-entity-public?
[entity]
(not (false? (:logseq.property/public? entity))))
(defn- property-entity->property
[entity]
{:schema {:type (or (:logseq.property/type entity) :default)
:cardinality (if (= :db.cardinality/many (:db/cardinality entity))
:many
:one)
:public? (property-entity-public? entity)}})
(defn- lookup-property-entity
[config repo property-key]
(let [lookup-by-title (fn [title]
(pull-property-by-name config repo title property-entity-selector))]
(cond
(number? property-key)
(pull-entity config repo property-entity-selector property-key)
(uuid? property-key)
(pull-entity config repo property-entity-selector [:block/uuid property-key])
(keyword? property-key)
(p/let [entity (pull-entity config repo property-entity-selector [:db/ident property-key])]
(if (or (:db/id entity) (qualified-keyword? property-key))
entity
(lookup-by-title (name property-key))))
(string? property-key)
(let [text (string/trim property-key)
ident (normalize-property-key text)]
(if-not (seq text)
(p/resolved nil)
(if (common-util/uuid-string? text)
(pull-entity config repo property-entity-selector [:block/uuid (uuid text)])
(p/let [entity (lookup-by-title text)]
(if (:db/id entity)
entity
(if ident
(pull-entity config repo property-entity-selector [:db/ident ident])
(p/resolved nil)))))))
:else
(p/resolved nil))))
(defn- resolve-property-entry-allow-non-built-in
[config repo property-key]
(p/let [entity (lookup-property-entity config repo property-key)
ident (:db/ident entity)]
(cond
(nil? (:db/id entity))
(throw (ex-info (str "property not found: " (pr-str property-key))
{:code :property-not-found
:property property-key}))
(not (property-entity? entity))
(throw (ex-info (str "This is not a property: " (pr-str property-key))
{:code :invalid-property-target
:property property-key}))
(nil? ident)
(throw (ex-info (str "property not found: " (pr-str property-key))
{:code :property-not-found
:property property-key}))
(not (property-entity-public? entity))
(throw (ex-info (str "property is not public: " (pr-str (name property-key)))
{:code :property-not-public
:property ident}))
:else
{:ident ident
:property (property-entity->property entity)})))
(defn resolve-properties
([config repo properties]
(resolve-properties config repo properties {:allow-non-built-in? false}))
([config repo properties {:keys [allow-non-built-in?]
:or {allow-non-built-in? false}}]
(if-not (seq properties)
(p/resolved nil)
(p/let [resolved-entries (p/all
(map (fn [[k v]]
(p/let [{:keys [ident property]}
(if allow-non-built-in?
(resolve-property-entry-allow-non-built-in config repo k)
(cond
(keyword? k)
(let [property (get db-property/built-in-properties k)]
(when-not property
(throw (ex-info "unknown built-in property"
{:code :unknown-property :property k})))
(when-not (property-public? property)
(throw (ex-info "property is not public"
{:code :property-not-public :property k})))
(p/resolved {:ident k :property property}))
(number? k)
(p/let [entity (pull-entity config repo [:db/ident] k)
ident (:db/ident entity)
property (get db-property/built-in-properties ident)]
(cond
(nil? ident)
(throw (ex-info "property not found"
{:code :property-not-found :property k}))
(nil? property)
(throw (ex-info "unknown built-in property"
{:code :unknown-property :property ident}))
(not (property-public? property))
(throw (ex-info "property is not public"
{:code :property-not-public :property ident}))
:else
{:ident ident :property property}))
(string? k)
(let [ident (or (property-title->ident k)
(normalize-property-key k))
property (get db-property/built-in-properties ident)]
(when-not property
(throw (ex-info "unknown built-in property"
{:code :unknown-property :property k})))
(when-not (property-public? property)
(throw (ex-info "property is not public"
{:code :property-not-public :property ident})))
(p/resolved {:ident ident :property property}))
:else
(p/rejected (ex-info "invalid property key"
{:code :invalid-property :property k}))))
{:keys [ok? value message]} (normalize-property-values property v)]
(when-not ok?
(throw (ex-info (str "invalid value for property " (pr-str (name k)) ": " message)
{:code :invalid-property-value
:property ident
:message message})))
(let [many? (= :many (get-in property [:schema :cardinality]))
values (if many?
(if (and (coll? value) (not (string? value))) value [value])
[value])]
(p/let [resolved (p/all (map #(resolve-property-value config repo property %) values))
final-value (if many? (vec resolved) (first resolved))]
[ident final-value]))))
properties))]
(into {} resolved-entries)))))
(defn resolve-property-identifiers
([config repo properties]
(resolve-property-identifiers config repo properties {:allow-non-built-in? false}))
([config repo properties {:keys [allow-non-built-in?]
:or {allow-non-built-in? false}}]
(if-not (seq properties)
(p/resolved nil)
(p/let [resolved-entries (p/all
(map (fn [k]
(if allow-non-built-in?
(p/let [{:keys [ident]} (resolve-property-entry-allow-non-built-in config repo k)]
ident)
(cond
(keyword? k)
(let [property (get db-property/built-in-properties k)]
(when-not property
(throw (ex-info "unknown built-in property"
{:code :unknown-property :property k})))
(when-not (property-public? property)
(throw (ex-info "property is not public"
{:code :property-not-public :property k})))
(p/resolved k))
(number? k)
(p/let [entity (pull-entity config repo [:db/ident] k)
ident (:db/ident entity)
property (get db-property/built-in-properties ident)]
(cond
(nil? ident)
(throw (ex-info "property not found"
{:code :property-not-found :property k}))
(nil? property)
(throw (ex-info "unknown built-in property"
{:code :unknown-property :property ident}))
(not (property-public? property))
(throw (ex-info "property is not public"
{:code :property-not-public :property ident}))
:else
ident))
(string? k)
(let [ident (or (property-title->ident k)
(normalize-property-key k))
property (get db-property/built-in-properties ident)]
(when-not property
(throw (ex-info "unknown built-in property"
{:code :unknown-property :property k})))
(when-not (property-public? property)
(throw (ex-info "property is not public"
{:code :property-not-public :property ident})))
(p/resolved ident))
:else
(p/rejected (ex-info "invalid property key"
{:code :invalid-property :property k})))))
properties))]
(vec resolved-entries)))))
(defn- resolve-add-target
[config {:keys [repo target-id target-uuid target-page-name]}]
(cond
(some? target-id)
(p/let [block (transport/invoke config :thread-api/pull
[repo [:db/id :block/uuid :block/title] target-id])]
(if-let [block-uuid (:block/uuid block)]
block-uuid
(throw (ex-info "target block not found" {:code :target-not-found}))))
(seq target-uuid)
(p/let [block (transport/invoke config :thread-api/pull
[repo [:db/id :block/uuid :block/title] [:block/uuid (uuid target-uuid)]])]
(if-let [block-uuid (:block/uuid block)]
block-uuid
(throw (ex-info "target block not found" {:code :target-not-found}))))
:else
(p/let [page-name (if (seq target-page-name) target-page-name (today-page-title config repo))
page-entity (ensure-page! config repo page-name)]
(or (:block/uuid page-entity)
(throw (ex-info "page not found" {:code :page-not-found}))))))
(defn- read-blocks
[options command-args]
(cond
(seq (:blocks options))
{:ok? true :value (reader/read-string (:blocks options))}
(seq (:blocks-file options))
(let [contents (.toString (fs/readFileSync (:blocks-file options)) "utf8")]
{:ok? true :value (reader/read-string contents)})
(seq (:content options))
{:ok? true :value [{:block/title (:content options)}]}
(seq command-args)
{:ok? true :value [{:block/title (string/join " " command-args)}]}
:else
{:ok? false
:error {:code :missing-content
:message "content is required"}}))
(defn- ensure-blocks
[value]
(if (vector? value)
{:ok? true :value value}
{:ok? false
:error {:code :invalid-blocks
:message "blocks must be a vector"}}))
(defn build-add-block-action
[options args repo]
(if-not (seq repo)
{:ok? false
:error {:code :missing-repo
: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))
tags-result (parse-tags-option (:tags options))
properties-result (parse-properties-option (:properties options))
tags (:value tags-result)
properties (:value properties-result)]
(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
(let [vector-result (ensure-blocks (:value blocks-result))]
(if-not (:ok? vector-result)
vector-result
(let [blocks (ensure-block-uuids (:value vector-result))]
{:ok? true
:action {:type :add-block
:repo repo
:graph (core/repo->graph repo)
:target-id (:target-id options)
:target-uuid (some-> (:target-uuid options) string/trim)
: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 execute-add-block
[action config]
(-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
target-block-uuid (resolve-add-target cfg action)
ref-values (collect-page-refs (:blocks action))
{:keys [uuid-refs page-refs id-refs]} (partition-ref-values ref-values)
_ (ensure-block-refs-exist! cfg (:repo action) uuid-refs)
page-refs' (or (resolve-page-ref-entities cfg (:repo action) page-refs) [])
id-refs' (or (resolve-id-ref-entities cfg (:repo action) id-refs) [])
refs (into page-refs' id-refs')
blocks (if (seq refs)
(normalize-block-title-refs (:blocks action) refs)
(:blocks action))
blocks-for-insert (flatten-block-tree blocks)
status (:status action)
tags (if (contains? action :resolved-tags)
(:resolved-tags action)
(resolve-tags cfg (:repo action) (:tags action)))
properties (if (contains? action :resolved-properties)
(:resolved-properties action)
(resolve-properties cfg (:repo action) (:properties action)))
remove-properties (if (contains? action :resolved-remove-properties)
(:resolved-remove-properties action)
(resolve-property-identifiers cfg (:repo action) (:remove-properties action)
{:allow-non-built-in? true}))
pos (:pos action)
keep-uuid? true
opts (case pos
"last-child" {:sibling? false :bottom? true}
"sibling" {:sibling? true}
{:sibling? false})
opts (cond-> opts
keep-uuid?
(assoc :keep-uuid? true))
block-uuids (->> blocks-for-insert
(map :block/uuid)
(remove nil?)
vec)
tag-ids (->> (or tags [])
(map :db/id)
(remove nil?)
distinct
vec)
ops (cond-> [[:insert-blocks [blocks-for-insert
target-block-uuid
(assoc opts :outliner-op :insert-blocks)]]]
(and (seq block-uuids) (seq remove-properties))
(into (map (fn [property-id]
[:batch-remove-property [block-uuids property-id]])
remove-properties))
(and status (seq block-uuids))
(conj [:batch-set-property [block-uuids :logseq.property/status status {}]])
(and (seq tag-ids) (seq block-uuids))
(into (map (fn [tag-id]
[:batch-set-property [block-uuids :block/tags tag-id {}]])
tag-ids))
(and (seq properties) (seq block-uuids))
(into (map (fn [[k v]]
[:batch-set-property [block-uuids k v {}]])
properties)))
apply-result (transport/invoke cfg :thread-api/apply-outliner-ops [(:repo action) ops {}])
created-ids (resolve-created-block-ids cfg (:repo action) blocks-for-insert apply-result)]
{:status :ok
:data {:result created-ids}})))