Merge pull request #12296 from logseq/feat/bi-directional-properties

feat: bi-directional property
This commit is contained in:
Tienson Qin
2026-01-22 00:53:06 +08:00
committed by GitHub
24 changed files with 775 additions and 87 deletions

View File

@@ -1,6 +1,7 @@
(ns user
"fns used on repl"
(:require [clojure.test :refer [run-tests run-test]]
[logseq.e2e.bidirectional-properties-test]
[logseq.e2e.block :as b]
[logseq.e2e.commands-basic-test]
[logseq.e2e.config :as config]
@@ -57,6 +58,11 @@
(->> (future (run-tests 'logseq.e2e.property-scoped-choices-test))
(swap! *futures assoc :property-scoped-choices-test)))
(defn run-bidirectional-properties-test
[]
(->> (future (run-tests 'logseq.e2e.bidirectional-properties-test))
(swap! *futures assoc :bidirectional-properties-test)))
(defn run-outliner-test
[]
(->> (future (run-tests 'logseq.e2e.outliner-basic-test))

View File

@@ -0,0 +1,51 @@
(ns logseq.e2e.bidirectional-properties-test
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[logseq.e2e.api :refer [ls-api-call!]]
[logseq.e2e.assert :as assert]
[logseq.e2e.fixtures :as fixtures]
[logseq.e2e.page :as page]
[wally.main :as w]))
(use-fixtures :once fixtures/open-page)
(use-fixtures :each
fixtures/new-logseq-page
fixtures/validate-graph)
(deftest bidirectional-properties-test
(testing "shows reverse property references when a class enables bidirectional properties"
(let [friend-prop "friend"
person-tag "Person"
project-tag "Project"
target "Bob"
container-page "Bidirectional Props"]
(ls-api-call! :editor.createTag person-tag
{:tagProperties [{:name friend-prop
:schema {:type "node"}}]})
(ls-api-call! :editor.createTag project-tag)
(let [person (ls-api-call! :editor.getTag person-tag)
person-uuid (get person "uuid")
friend (ls-api-call! :editor.getPage friend-prop)]
(ls-api-call! :editor.upsertBlockProperty (get friend "id")
"logseq.property/classes"
(get person "id"))
(is (string? person-uuid))
(ls-api-call! :editor.upsertBlockProperty person-uuid
"logseq.property.class/bidirectional-property-title"
"People")
(ls-api-call! :editor.upsertBlockProperty person-uuid
"logseq.property.class/enable-bidirectional?"
true))
(ls-api-call! :editor.createPage target)
(ls-api-call! :editor.createPage container-page)
(let [bob (ls-api-call! :editor.getPage target)
bob-id (get bob "id")]
(ls-api-call! :editor.insertBlock container-page (str "Alice #" person-tag)
{:properties {friend-prop bob-id}})
(ls-api-call! :editor.insertBlock container-page (str "Charlie #" project-tag)
{:properties {friend-prop bob-id}}))
(page/goto-page target)
(w/wait-for ".property-k:text('People')")
(assert/assert-is-visible ".property-value .block-title-wrap:text('Alice')")
(assert/assert-have-count ".property-k:text('Projects')" 0))))

View File

@@ -6,4 +6,9 @@ logseq.common.graph/read-directories
;; Profile utils
logseq.common.profile/profile-fn!
logseq.common.profile/*key->call-count
logseq.common.profile/*key->time-sum
logseq.common.profile/*key->time-sum
;; API fn
logseq.common.plural/is-plural?
logseq.common.plural/is-singular?
logseq.common.plural/pluralize

3
deps/common/bb.edn vendored
View File

@@ -23,4 +23,5 @@
:tasks/config
{:large-vars
{:max-lines-count 45}}}
{:metadata-exceptions #{:large-vars/cleanup-todo}
:max-lines-count 45}}}

View File

@@ -0,0 +1,333 @@
(ns logseq.common.plural
"ClojureScript port of pluralize.js core (rules + API).
Usage:
(pluralize \"duck\" 2 true) ;; => \"2 ducks\"
(plural \"person\") ;; => \"people\"
(singular \"people\") ;; => \"person\"
(is-plural? \"ducks\") ;; => true
(is-singular? \"duck\") ;; => true
You can add rules at runtime:
(add-plural-rule! #\"(ox)$\" \"$1en\")
(add-uncountable-rule! \"metadata\")"
(:require [clojure.string :as string]))
;; -----------------------------------------------------------------------------
;; Rule storage (mirrors original semantics)
;; pluralize and singularize must run rules sequentially.
;; -----------------------------------------------------------------------------
(defonce ^:private plural-rules (atom [])) ;; vector of [js/RegExp replacement]
(defonce ^:private singular-rules (atom [])) ;; vector of [js/RegExp replacement]
(defonce ^:private uncountables (atom {})) ;; token -> true
(defonce ^:private irregular-plurals (atom {})) ;; plural -> singular
(defonce ^:private irregular-singles (atom {})) ;; singular -> plural
;; -----------------------------------------------------------------------------
;; Helpers
;; -----------------------------------------------------------------------------
(defn- sanitize-rule
"If rule is a string, compile to case-insensitive regexp that matches the whole string.
Else keep it (assumed to be js/RegExp)."
[rule]
(if (string? rule)
(js/RegExp. (str "^" rule "$") "i")
rule))
(defn- restore-case
"Replicate casing of `word` onto `token`."
[word token]
(cond
(= word token)
token
(= word (string/lower-case word))
(string/lower-case token)
(= word (string/upper-case word))
(string/upper-case token)
(and (seq word)
(= (subs word 0 1) (string/upper-case (subs word 0 1))))
(str (string/upper-case (subs token 0 1))
(string/lower-case (subs token 1)))
:else
(string/lower-case token)))
(defn- interpolate
"Replace $1..$12 etc in `s` using JS replace args (match, g1, g2 ...)."
[s js-args]
(.replace s (js/RegExp. "\\$(\\d{1,2})" "g")
(fn [_ idx]
(let [i (js/parseInt idx 10)
v (aget js-args i)]
(or v "")))))
(defn- replace-with-rule
"Apply a [re repl] rule to word with casing restoration (matches JS behavior)."
[word [re repl]]
(.replace word re
(fn [& args]
;; args: [match g1 g2 ... offset string]
(let [match (nth args 0)
;; In JS replace callback, second-to-last is offset
offset (nth args (- (count args) 2))
;; interpolate expects JS-ish indexed args;
;; easiest is to turn args into a JS array.
js-args (to-array args)
result (interpolate repl js-args)]
(if (= match "")
;; match empty => restore based on char before match
(restore-case (subs word (dec offset) offset) result)
(restore-case match result))))))
(defn- sanitize-word
"Return sanitized `word` based on `token` and `rules`."
[token word rules]
(cond
(or (zero? (count token))
(contains? @uncountables token))
word
:else
(let [rs rules
;; JS iterates from end to start
n (count rs)]
(loop [i (dec n)]
(if (neg? i)
word
(let [[re _ :as rule] (nth rs i)]
(if (.test re word)
(replace-with-rule word rule)
(recur (dec i)))))))))
(defn- replace-word-fn
"Build a word transformer (plural or singular)."
[replace-map-atom keep-map-atom rules-atom]
(fn [word]
(let [token (string/lower-case word)
keep-map @keep-map-atom
replace-map @replace-map-atom
rules @rules-atom]
(cond
(contains? keep-map token)
(restore-case word token)
(contains? replace-map token)
(restore-case word (get replace-map token))
:else
(sanitize-word token word rules)))))
(defn- check-word-fn
"Build a predicate for whether word is plural/singular (mirrors JS `checkWord`)."
[replace-map-atom keep-map-atom rules-atom]
(fn [word]
(let [token (string/lower-case word)
keep-map @keep-map-atom
replace-map @replace-map-atom
rules @rules-atom]
(cond
(contains? keep-map token) true
(contains? replace-map token) false
:else (= (sanitize-word token token rules) token)))))
;; -----------------------------------------------------------------------------
;; Public API (matches original surface)
;; -----------------------------------------------------------------------------
(def plural (replace-word-fn irregular-singles irregular-plurals plural-rules))
(def singular (replace-word-fn irregular-plurals irregular-singles singular-rules))
(def is-plural? (check-word-fn irregular-singles irregular-plurals plural-rules))
(def is-singular? (check-word-fn irregular-plurals irregular-singles singular-rules))
(defn pluralize
"Pluralize or singularize based on count. If inclusive, prefix with count."
([word item-count] (pluralize word item-count false))
([word item-count inclusive]
(let [pluralized (if (= item-count 1) (singular word) (plural word))]
(str (when inclusive (str item-count " "))
pluralized))))
(defn add-plural-rule!
[rule replacement]
(swap! plural-rules conj [(sanitize-rule rule) replacement]))
(defn add-singular-rule!
[rule replacement]
(swap! singular-rules conj [(sanitize-rule rule) replacement]))
(defn add-uncountable-rule!
"If word is string => mark as uncountable.
If regexp => add plural+singular passthrough rules ($0)."
[word]
(if (string? word)
(swap! uncountables assoc (string/lower-case word) true)
(do
(add-plural-rule! word "$0")
(add-singular-rule! word "$0"))))
(defn add-irregular-rule!
[single plural-word]
(let [p (string/lower-case plural-word)
s (string/lower-case single)]
(swap! irregular-singles assoc s p)
(swap! irregular-plurals assoc p s)))
;; -----------------------------------------------------------------------------
;; Data initialization (same as original JS)
;; -----------------------------------------------------------------------------
(defn- ^:large-vars/cleanup-todo init-irregulars! []
(doseq [[s p]
;; Pronouns + irregulars
[["I" "we"]
["me" "us"]
["he" "they"]
["she" "they"]
["them" "them"]
["myself" "ourselves"]
["yourself" "yourselves"]
["itself" "themselves"]
["herself" "themselves"]
["himself" "themselves"]
["themself" "themselves"]
["is" "are"]
["was" "were"]
["has" "have"]
["this" "these"]
["that" "those"]
["my" "our"]
["its" "their"]
["his" "their"]
["her" "their"]
;; Words ending with consonant + o
["echo" "echoes"]
["dingo" "dingoes"]
["volcano" "volcanoes"]
["tornado" "tornadoes"]
["torpedo" "torpedoes"]
;; Ends with us
["genus" "genera"]
["viscus" "viscera"]
;; Ends with ma
["stigma" "stigmata"]
["stoma" "stomata"]
["dogma" "dogmata"]
["lemma" "lemmata"]
["schema" "schemata"]
["anathema" "anathemata"]
;; Other irregular
["ox" "oxen"]
["axe" "axes"]
["die" "dice"]
["yes" "yeses"]
["foot" "feet"]
["eave" "eaves"]
["goose" "geese"]
["tooth" "teeth"]
["quiz" "quizzes"]
["human" "humans"]
["proof" "proofs"]
["carve" "carves"]
["valve" "valves"]
["looey" "looies"]
["thief" "thieves"]
["groove" "grooves"]
["pickaxe" "pickaxes"]
["passerby" "passersby"]
["canvas" "canvases"]]]
(add-irregular-rule! s p)))
(defn- init-plural-rules! []
(doseq [[rule repl]
[[(js/RegExp. "s?$" "i") "s"]
[(js/RegExp. "[^\\u0000-\\u007F]$" "i") "$0"]
[(js/RegExp. "([^aeiou]ese)$" "i") "$1"]
[(js/RegExp. "(ax|test)is$" "i") "$1es"]
[(js/RegExp. "(alias|[^aou]us|t[lm]as|gas|ris)$" "i") "$1es"]
[(js/RegExp. "(e[mn]u)s?$" "i") "$1s"]
[(js/RegExp. "([^l]ias|[aeiou]las|[ejzr]as|[iu]am)$" "i") "$1"]
[(js/RegExp. "(alumn|syllab|vir|radi|nucle|fung|cact|stimul|termin|bacill|foc|uter|loc|strat)(?:us|i)$" "i") "$1i"]
[(js/RegExp. "(alumn|alg|vertebr)(?:a|ae)$" "i") "$1ae"]
[(js/RegExp. "(seraph|cherub)(?:im)?$" "i") "$1im"]
[(js/RegExp. "(her|at|gr)o$" "i") "$1oes"]
[(js/RegExp. "(agend|addend|millenni|dat|extrem|bacteri|desiderat|strat|candelabr|errat|ov|symposi|curricul|automat|quor)(?:a|um)$" "i") "$1a"]
[(js/RegExp. "(apheli|hyperbat|periheli|asyndet|noumen|phenomen|criteri|organ|prolegomen|hedr|automat)(?:a|on)$" "i") "$1a"]
[(js/RegExp. "sis$" "i") "ses"]
[(js/RegExp. "(?:(kni|wi|li)fe|(ar|l|ea|eo|oa|hoo)f)$" "i") "$1$2ves"]
[(js/RegExp. "([^aeiouy]|qu)y$" "i") "$1ies"]
[(js/RegExp. "([^ch][ieo][ln])ey$" "i") "$1ies"]
[(js/RegExp. "(x|ch|ss|sh|zz)$" "i") "$1es"]
[(js/RegExp. "(matr|cod|mur|sil|vert|ind|append)(?:ix|ex)$" "i") "$1ices"]
[(js/RegExp. "\\b((?:tit)?m|l)(?:ice|ouse)$" "i") "$1ice"]
[(js/RegExp. "(pe)(?:rson|ople)$" "i") "$1ople"]
[(js/RegExp. "(child)(?:ren)?$" "i") "$1ren"]
[(js/RegExp. "eaux$" "i") "$0"]
[(js/RegExp. "m[ae]n$" "i") "men"]
["thou" "you"]]]
(add-plural-rule! rule repl)))
(defn- init-singular-rules! []
(doseq [[rule repl]
[[(js/RegExp. "s$" "i") ""]
[(js/RegExp. "(ss)$" "i") "$1"]
[(js/RegExp. "(wi|kni|(?:after|half|high|low|mid|non|night|[^\\w]|^)li)ves$" "i") "$1fe"]
[(js/RegExp. "(ar|(?:wo|[ae])l|[eo][ao])ves$" "i") "$1f"]
[(js/RegExp. "ies$" "i") "y"]
[(js/RegExp. "(dg|ss|ois|lk|ok|wn|mb|th|ch|ec|oal|is|ck|ix|sser|ts|wb)ies$" "i") "$1ie"]
[(js/RegExp. "\\b(l|(?:neck|cross|hog|aun)?t|coll|faer|food|gen|goon|group|hipp|junk|vegg|(?:pork)?p|charl|calor|cut)ies$" "i") "$1ie"]
[(js/RegExp. "\\b(mon|smil)ies$" "i") "$1ey"]
[(js/RegExp. "\\b((?:tit)?m|l)ice$" "i") "$1ouse"]
[(js/RegExp. "(seraph|cherub)im$" "i") "$1"]
[(js/RegExp. "(x|ch|ss|sh|zz|tto|go|cho|alias|[^aou]us|t[lm]as|gas|(?:her|at|gr)o|[aeiou]ris)(?:es)?$" "i") "$1"]
[(js/RegExp. "(analy|diagno|parenthe|progno|synop|the|empha|cri|ne)(?:sis|ses)$" "i") "$1sis"]
[(js/RegExp. "(movie|twelve|abuse|e[mn]u)s$" "i") "$1"]
[(js/RegExp. "(test)(?:is|es)$" "i") "$1is"]
[(js/RegExp. "(alumn|syllab|vir|radi|nucle|fung|cact|stimul|termin|bacill|foc|uter|loc|strat)(?:us|i)$" "i") "$1us"]
[(js/RegExp. "(agend|addend|millenni|dat|extrem|bacteri|desiderat|strat|candelabr|errat|ov|symposi|curricul|quor)a$" "i") "$1um"]
[(js/RegExp. "(apheli|hyperbat|periheli|asyndet|noumen|phenomen|criteri|organ|prolegomen|hedr|automat)a$" "i") "$1on"]
[(js/RegExp. "(alumn|alg|vertebr)ae$" "i") "$1a"]
[(js/RegExp. "(cod|mur|sil|vert|ind)ices$" "i") "$1ex"]
[(js/RegExp. "(matr|append)ices$" "i") "$1ix"]
[(js/RegExp. "(pe)(rson|ople)$" "i") "$1rson"]
[(js/RegExp. "(child)ren$" "i") "$1"]
[(js/RegExp. "(eau)x?$" "i") "$1"]
[(js/RegExp. "men$" "i") "man"]]]
(add-singular-rule! rule repl)))
(defn- init-uncountables! []
(doseq [w
["adulthood" "advice" "agenda" "aid" "aircraft" "alcohol" "ammo"
"analytics" "anime" "athletics" "audio" "bison" "blood" "bream"
"buffalo" "butter" "carp" "cash" "chassis" "chess" "clothing" "cod"
"commerce" "cooperation" "corps" "debris" "diabetes" "digestion" "elk"
"energy" "equipment" "excretion" "expertise" "firmware" "flounder"
"fun" "gallows" "garbage" "graffiti" "hardware" "headquarters" "health"
"herpes" "highjinks" "homework" "housework" "information" "jeans"
"justice" "kudos" "labour" "literature" "machinery" "mackerel" "mail"
"media" "mews" "moose" "music" "mud" "manga" "news" "only" "personnel"
"pike" "plankton" "pliers" "police" "pollution" "premises" "rain"
"research" "rice" "salmon" "scissors" "series" "sewage" "shambles"
"shrimp" "software" "staff" "swine" "tennis" "traffic"
"transportation" "trout" "tuna" "wealth" "welfare" "whiting"
"wildebeest" "wildlife" "you"]]
(add-uncountable-rule! w))
(doseq [re [(js/RegExp. "pok[eé]mon$" "i")
(js/RegExp. "[^aeiou]ese$" "i")
(js/RegExp. "deer$" "i")
(js/RegExp. "fish$" "i")
(js/RegExp. "measles$" "i")
(js/RegExp. "o[iu]s$" "i")
(js/RegExp. "pox$" "i")
(js/RegExp. "sheep$" "i")]]
(add-uncountable-rule! re)))
(init-irregulars!)
(init-plural-rules!)
(init-singular-rules!)
(init-uncountables!)

View File

@@ -9,6 +9,7 @@
[datascript.core :as d]
[datascript.impl.entity :as de]
[logseq.common.config :as common-config]
[logseq.common.plural :as common-plural]
[logseq.common.util :as common-util]
[logseq.common.uuid :as common-uuid]
[logseq.db.common.delete-blocks :as delete-blocks] ;; Load entity extensions
@@ -677,3 +678,69 @@
(recur (:block/parent parent)))))))
(def get-class-title-with-extends db-db/get-class-title-with-extends)
(defn- bidirectional-property-attr?
[db attr]
(when (qualified-keyword? attr)
(let [attr-ns (namespace attr)]
(and (or (db-property/user-property-namespace? attr-ns)
(db-property/plugin-property? attr))
(when-let [property (d/entity db attr)]
(= :db.type/ref (:db/valueType property)))))))
(defn- get-ea-by-v
[db v]
(d/q '[:find ?e ?a
:in $ ?v
:where
[?e ?a ?v]
[?ea :db/ident ?a]
[?ea :logseq.property/classes]]
db
v))
(defn get-bidirectional-properties
"Given a target entity id, returns a seq of maps with:
* :class - class entity
* :title - pluralized class title
* :entities - node entities that reference the target via ref properties"
[db target-id]
(when (and db target-id (d/entity db target-id))
(let [add-entity
(fn [acc class-id entity]
(if class-id
(update acc class-id (fnil conj #{}) entity)
acc))]
(->> (get-ea-by-v db target-id)
(keep (fn [[e a]]
(when (bidirectional-property-attr? db a)
(when-let [entity (d/entity db e)]
(when (and (not= (:db/id entity) target-id)
(not (entity-util/class? entity))
(not (entity-util/property? entity)))
(let [classes (filter entity-util/class? (:block/tags entity))]
(when (seq classes)
(keep (fn [class-ent]
(when-not (built-in? class-ent)
[(:db/id class-ent) entity]))
classes))))))))
(mapcat identity)
(reduce (fn [acc [class-ent entity]]
(add-entity acc class-ent entity))
{})
(keep (fn [[class-id entities]]
(let [class (d/entity db class-id)]
(when (true? (:logseq.property.class/enable-bidirectional? class))
(let [custom-title (when-let [custom (:logseq.property.class/bidirectional-property-title class)]
(if (string? custom)
custom
(db-property/property-value-content custom)))
title (if (string/blank? custom-title)
(common-plural/plural (:block/title class))
custom-title)]
{:title title
:class (-> (into {} class)
(assoc :db/id (:db/id class)))
:entities (->> entities
(sort-by :block/created-at))})))))
(sort-by (comp :block/created-at :class))))))

View File

@@ -182,6 +182,16 @@
:cardinality :many
:public? true
:view-context :never}}
:logseq.property.class/bidirectional-property-title {:title "Bidirectional property title"
:schema {:type :string
:public? true
:view-context :class}}
:logseq.property.class/enable-bidirectional? {:title "Enable bidirectional properties"
:schema {:type :checkbox
:public? true
:view-context :class}
:properties
{:logseq.property/description "When enabled, this tag will show reverse nodes that link to the current node via properties."}}
:logseq.property/hide-empty-value {:title "Hide empty value"
:schema {:type :checkbox
:public? true

View File

@@ -37,7 +37,7 @@
(map (juxt :major :minor)
[(parse-schema-version x) (parse-schema-version y)])))
(def version (parse-schema-version "65.19"))
(def version (parse-schema-version "65.20"))
(defn major-version
"Return a number.

View File

@@ -108,4 +108,43 @@
(fn [temp-conn]
(ldb/transact! temp-conn [{:db/ident :logseq.class/Task
:block/tags :logseq.class/Property}])
(ldb/transact! temp-conn [[:db/retract :logseq.class/Task :block/tags :logseq.class/Property]]))))))
(ldb/transact! temp-conn [[:db/retract :logseq.class/Task :block/tags :logseq.class/Property]]))))))
(deftest get-bidirectional-properties
(testing "disabled by default"
(let [conn (db-test/create-conn-with-blocks
{:properties {:friend {:logseq.property/type :node
:build/property-classes [:Person]}}
:classes {:Person {}
:Project {}}
:pages-and-blocks
[{:page {:block/title "Alice"
:build/tags [:Person]
:build/properties {:friend [:build/page {:block/title "Bob"}]}}}
{:page {:block/title "Bob"}}
{:page {:block/title "Charlie"
:build/tags [:Project]
:build/properties {:friend [:build/page {:block/title "Bob"}]}}}]})
target (db-test/find-page-by-title @conn "Bob")]
(is (empty? (ldb/get-bidirectional-properties @conn (:db/id target))))))
(testing "enabled per class"
(let [conn (db-test/create-conn-with-blocks
{:properties {:friend {:logseq.property/type :node
:build/property-classes [:Person]}}
:classes {:Person {:build/properties {:logseq.property.class/enable-bidirectional? true}}
:Project {}}
:pages-and-blocks
[{:page {:block/title "Alice"
:build/tags [:Person]
:build/properties {:friend [:build/page {:block/title "Bob"}]}}}
{:page {:block/title "Bob"}}
{:page {:block/title "Charlie"
:build/tags [:Project]
:build/properties {:friend [:build/page {:block/title "Bob"}]}}}]})
target (db-test/find-page-by-title @conn "Bob")
results (ldb/get-bidirectional-properties @conn (:db/id target))]
(is (= 1 (count results)))
(is (= "People" (:title (first results))))
(is (= ["Alice"]
(map :block/title (:entities (first results))))))))

View File

@@ -584,7 +584,6 @@
(= existing-value v'))]
(throw-error-if-self-value block v' ref?)
(prn :debug :value-matches? value-matches?)
(when-not value-matches?
(raw-set-block-property! conn block property v'))))))))

View File

@@ -1744,6 +1744,7 @@
doc-mode? (state/sub :document/mode?)
control-show? (util/react *control-show?)
ref? (:ref? config)
container-id (:container-id config)
empty-content? (block-content-empty? block)
fold-button-right? (state/enable-fold-button-right?)
own-number-list? (:own-order-number-list? config)
@@ -1774,9 +1775,10 @@
:on-click (fn [event]
(util/stop event)
(state/clear-edit!)
(state/set-state! :editor/container-id container-id)
(p/do!
(if ref?
(state/toggle-collapsed-block! uuid)
(state/toggle-collapsed-block! uuid container-id)
(if collapsed?
(editor-handler/expand-block! uuid)
(editor-handler/collapse-block! uuid)))
@@ -2984,7 +2986,7 @@
(:view? config)
(root-block? config block)
(and (or (ldb/class? block) (ldb/property? block)) (:page-title? config)))
(state/sub-block-collapsed uuid)
(state/sub-block-collapsed uuid container-id)
:else
db-collapsed?)
@@ -3244,10 +3246,12 @@
(boolean result)))
(defn- set-collapsed-block!
[block-id v]
[block-id v container-id]
(if (false? v)
(editor-handler/expand-block! block-id {:skip-db-collpsing? true})
(state/set-collapsed-block! block-id v)))
(do
(editor-handler/expand-block! block-id {:skip-db-collpsing? true})
(state/set-collapsed-block! block-id v container-id))
(state/set-collapsed-block! block-id v container-id)))
(rum/defcs loaded-block-container < rum/reactive db-mixins/query
(rum/local false ::show-block-left-menu?)
@@ -3257,19 +3261,23 @@
(let [[config block] (:rum/args state)
block-id (:block/uuid block)
linked-block? (or (:block/link block)
(:original-block config))]
(:original-block config))
container-id (if (or linked-block? (nil? (:container-id config)))
(state/get-next-container-id)
(:container-id config))]
(when-not (:property-block? config)
(cond
(and (:page-title? config) (or (ldb/class? block) (ldb/property? block)) (not config/publishing?))
(let [collapsed? (state/get-block-collapsed block-id)]
(set-collapsed-block! block-id (if (some? collapsed?) collapsed? true)))
(let [collapsed? (state/get-block-collapsed block-id container-id)]
(set-collapsed-block! block-id (if (some? collapsed?) collapsed? true) container-id))
(root-block? config block)
(set-collapsed-block! block-id false)
(set-collapsed-block! block-id false container-id)
(or (:view? config) (:ref? config) (:custom-query? config))
(set-collapsed-block! block-id
(boolean (editor-handler/block-default-collapsed? block config)))
(boolean (editor-handler/block-default-collapsed? block config))
container-id)
:else
nil))
@@ -3277,14 +3285,15 @@
(assoc state
::control-show? (atom false)
::navigating-block (atom (:block/uuid block)))
(or linked-block? (nil? (:container-id config)))
(assoc ::container-id (state/get-next-container-id)))))
(and container-id (or linked-block? (nil? (:container-id config))))
(assoc ::container-id container-id))))
:will-unmount (fn [state]
;; restore root block's collapsed state
(let [[config block] (:rum/args state)
block-id (:block/uuid block)]
block-id (:block/uuid block)
container-id (or (:container-id config) (::container-id state))]
(when (root-block? config block)
(set-collapsed-block! block-id nil)))
(set-collapsed-block! block-id nil container-id)))
state)}
[state config block & {:as opts}]
(let [repo (state/get-current-repo)
@@ -3318,7 +3327,8 @@
(p/let [block (db-async/<get-block (state/get-current-repo)
id
{:children? (not
(if-some [result (state/get-block-collapsed (:block/uuid block))]
(if-some [result (state/get-block-collapsed (:block/uuid block)
(:container-id config))]
result
(:block/collapsed? block)))
:skip-refresh? false})]

View File

@@ -1131,6 +1131,10 @@ html.is-mac {
.block-tags {
margin-top: 17px;
}
.ls-properties-area .block-tags {
margin-top: 0;
}
}
.ls-page-title .ls-properties-area {

View File

@@ -341,6 +341,48 @@
(:block/title property)]
(property-key-title block property class-schema?))]))
(defn- bidirectional-property-icon-cp
[property]
(if-let [icon (:logseq.property/icon property)]
(icon-component/icon icon {:size 15 :color? true})
(ui/icon "letter-b" {:class "opacity-50" :size 15})))
(rum/defcs bidirectional-values-cp < rum/static
{:init (fn [state]
(assoc state ::container-id (state/get-next-container-id)))}
[state entities]
(let [blocks-container (state/get-component :block/blocks-container)
container-id (::container-id state)
config {:id (str "bidirectional-" container-id)
:container-id container-id
:editor-box (state/get-component :editor/box)
:default-collapsed? true
:ref? true}]
(if (and blocks-container (seq entities))
[:div.property-block-container.content.w-full
(blocks-container config entities)]
[:span.opacity-60 "Empty"])))
(rum/defc bidirectional-properties-section < rum/static
[bidirectional-properties]
(when (seq bidirectional-properties)
(for [{:keys [class title entities]} bidirectional-properties]
[:div.property-pair.items-start {:key (str "bidirectional-" title)}
[:div.property-key
[:div.property-key-inner
[:div.property-icon
(bidirectional-property-icon-cp class)]
(if class
[:a.property-k.flex.select-none.w-full.jtrigger
{:on-click (fn [e]
(util/stop e)
(route-handler/redirect-to-page! (:block/uuid class)))}
title]
[:div.property-k.flex.select-none.w-full title])]]
[:div.ls-block.property-value-container.flex.flex-row.gap-1.items-start
[:div.property-value.flex.flex-1
(bidirectional-values-cp entities)]]])))
(rum/defcs ^:large-vars/cleanup-todo property-input < rum/reactive
(rum/local false ::show-new-property-config?)
(rum/local false ::show-class-select?)
@@ -584,7 +626,18 @@
[:div.mt-1
(properties-section block hidden-properties opts)]]))
(rum/defc load-bidirectional-properties < rum/static
[block root-block? set-bidirectional-properties!]
(hooks/use-effect!
(fn []
(when (and root-block? (:db/id block))
(p/let [result (db-async/<get-bidirectional-properties (:db/id block))]
(set-bidirectional-properties! result)))
(fn []))
[root-block? (:db/id block)]))
(rum/defcs ^:large-vars/cleanup-todo properties-area < rum/reactive db-mixins/query
(rum/local nil ::bidirectional-properties)
{:init (fn [state]
(let [target-block (first (:rum/args state))
block (resolve-linked-block-if-exists target-block)]
@@ -592,7 +645,9 @@
::id (str (random-uuid))
::block block)))}
[state _target-block {:keys [page-title? journal-page? sidebar-properties? tag-dialog?] :as opts}]
(let [id (::id state)
(let [*bidirectional-properties (::bidirectional-properties state)
bidirectional-properties @*bidirectional-properties
id (::id state)
db-id (:db/id (::block state))
block (db/sub-block db-id)
show-properties? (or sidebar-properties? tag-dialog?)
@@ -600,7 +655,11 @@
(and show?
(or (= mode :global)
(and (set? ids) (contains? ids (:block/uuid block))))))
properties (:block/properties block)
properties (cond-> (:block/properties block)
(and (ldb/class? block)
(not (ldb/built-in? block)))
(assoc :logseq.property.class/enable-bidirectional?
(:logseq.property.class/enable-bidirectional? block)))
remove-built-in-or-other-position-properties
(fn [properties show-in-hidden-properties?]
(remove (fn [property]
@@ -682,48 +741,53 @@
(state/get-current-page))
(and (= (str (:block/uuid block)) (:id opts))
(not (entity-util/page? block))))]
(cond
(and (empty? full-properties) (seq hidden-properties) (not root-block?) (not sidebar-properties?))
nil
[:<>
(load-bidirectional-properties block root-block? #(reset! *bidirectional-properties %))
(let [has-bidirectional-properties? (seq bidirectional-properties)]
(cond
(and (empty? full-properties) (seq hidden-properties) (not root-block?) (not sidebar-properties?)
(not has-bidirectional-properties?))
nil
(and (empty? full-properties) (empty? hidden-properties))
(when show-properties?
(rum/with-key (new-property block opts) (str id "-add-property")))
(and (empty? full-properties) (empty? hidden-properties) (not has-bidirectional-properties?))
(when show-properties?
(rum/with-key (new-property block opts) (str id "-add-property")))
:else
(let [remove-properties #{:logseq.property/icon :logseq.property/query}
properties' (->> (remove (fn [[k _v]] (contains? remove-properties k))
full-properties)
(remove (fn [[k _v]] (= k :logseq.property.class/properties))))
page? (entity-util/page? block)
class? (entity-util/class? block)]
[:div.ls-properties-area
{:id id
:class (util/classnames [{:ls-page-properties page?}])
:tab-index 0}
[:<>
(properties-section block properties' opts)
:else
(let [remove-properties #{:logseq.property/icon :logseq.property/query}
properties' (->> (remove (fn [[k _v]] (contains? remove-properties k))
full-properties)
(remove (fn [[k _v]] (= k :logseq.property.class/properties))))
page? (entity-util/page? block)
class? (entity-util/class? block)]
[:div.ls-properties-area
{:id id
:class (util/classnames [{:ls-page-properties page?}])
:tab-index 0}
[:<>
(properties-section block properties' opts)
(bidirectional-properties-section bidirectional-properties)
(when-not class?
(hidden-properties-cp block hidden-properties
(assoc opts :root-block? root-block?)))
(when-not class?
(hidden-properties-cp block hidden-properties
(assoc opts :root-block? root-block?)))
(when (and page? (not class?))
(rum/with-key (new-property block opts) (str id "-add-property")))
(when (and page? (not class?))
(rum/with-key (new-property block opts) (str id "-add-property")))
(when class?
(let [properties (->> (:logseq.property.class/properties block)
(map (fn [e] [(:db/ident e)])))
opts' (assoc opts :class-schema? true)]
[:div.flex.flex-col.gap-1
[:div {:style {:font-size 15}}
[:div.property-pair
[:div.property-key.text-sm
(property-key-cp block (db/entity :logseq.property.class/properties) {})]]
[:div.text-muted-foreground {:style {:margin-left 26}}
"Tag properties are inherited by all nodes using the tag. For example, each #Task node inherits 'Status' and 'Priority'."]]
[:div.ml-4
(properties-section block properties opts')
(hidden-properties-cp block hidden-properties
(assoc opts :root-block? root-block?))
(rum/with-key (new-property block opts') (str id "-class-add-property"))]]))]]))))
(when class?
(let [properties (->> (:logseq.property.class/properties block)
(map (fn [e] [(:db/ident e)])))
opts' (assoc opts :class-schema? true)]
[:div.flex.flex-col.gap-1
[:div {:style {:font-size 15}}
[:div.property-pair
[:div.property-key.text-sm
(property-key-cp block (db/entity :logseq.property.class/properties) {})]]
[:div.text-muted-foreground {:style {:margin-left 26}}
"Tag properties are inherited by all nodes using the tag. For example, each #Task node inherits 'Status' and 'Priority'."]]
[:div.ml-4
(properties-section block properties opts')
(hidden-properties-cp block hidden-properties
(assoc opts :root-block? root-block?))
(rum/with-key (new-property block opts') (str id "-class-add-property"))]]))]])))]))

View File

@@ -42,7 +42,6 @@
[promesa.core :as p]
[rum.core :as rum]))
;; TODO: support :string editing
(defonce string-value-on-click
{:logseq.property.asset/external-url
(fn [block property]
@@ -1074,6 +1073,69 @@
:else
(property-normal-block-value block property v-block opts))))
(rum/defc single-string-input
[block property value table-view?]
(let [[editing? set-editing!] (hooks/use-state false)
*ref (hooks/use-ref nil)
string-value (cond
(string? value) value
(some? value) (str (db-property/property-value-content value))
:else "")
[value set-value!] (hooks/use-state string-value)
set-property-value! (fn [value & {:keys [exit-editing?]
:or {exit-editing? true}}]
(let [next-value (or value "")
blank? (string/blank? next-value)]
(p/do!
(if blank?
(when (get block (:db/ident property))
(db-property-handler/remove-block-property! (:db/id block) (:db/ident property)))
(when (not= string-value next-value)
(db-property-handler/set-block-property! (:db/id block)
(:db/ident property)
next-value)))
(set-value! (or (get (db/entity (:db/id block)) (:db/ident property)) ""))
(when exit-editing?
(set-editing! false)))))]
(hooks/use-effect!
(fn []
(set-value! string-value)
#())
[string-value])
[:div.ls-string.flex.flex-1.jtrigger
{:ref *ref
:on-click #(do
(state/clear-selection!)
(set-editing! true))}
(if editing?
(shui/input
{:auto-focus true
:class (str "ls-string-input h-6 px-0 py-0 border-none bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 text-base"
(when table-view? " text-sm"))
:value value
:on-change (fn [e]
(set-value! (util/evalue e)))
:on-blur (fn [_e]
(p/do!
(set-property-value! value)))
:on-key-down (fn [e]
(case (util/ekey e)
"Enter"
(do
(util/stop e)
(set-property-value! value))
"Escape"
(do
(util/stop e)
(set-value! string-value)
(set-editing! false)
(some-> (rum/deref *ref) (.focus)))
nil))})
(if (string/blank? string-value)
(property-empty-text-value property {:table-view? table-view?})
string-value))]))
(rum/defc closed-value-item < rum/reactive db-mixins/query
[value {:keys [inline-text icon?]}]
(when value
@@ -1372,6 +1434,9 @@
(and (= type :number) (not editing?) (not closed-values?))
(single-number-input block property value (:table-view? opts))
(= type :string)
(single-string-input block property value (:table-view? opts))
:else
(if (and select-type?'
(not (and (not closed-values?) (= type :date))))

View File

@@ -1,4 +1,4 @@
.property-value-inner:not([data-type="default"]):not([data-type="url"]):not([data-type="number"]):not([data-type="date"]):not([data-type="datetime"]) {
.property-value-inner:not([data-type="default"]):not([data-type="url"]):not([data-type="number"]):not([data-type="string"]):not([data-type="date"]):not([data-type="datetime"]) {
@apply cursor-pointer;
&:hover, .as-scalar-value-wrap:hover {
@apply bg-gray-02 rounded transition-[background-color] duration-300;
@@ -41,6 +41,11 @@
min-height: 20px;
}
.ls-string {
@apply cursor-text;
min-height: 20px;
}
.ls-repeat-task-frequency .property-value-inner {
@apply border rounded pl-2;
min-width: 3em;

View File

@@ -111,8 +111,8 @@
(str result-count (if (> result-count 1) " results" " result"))])]))
(defn- calculate-collapsed?
[current-block current-block-uuid {:keys [collapsed?]}]
(let [temp-collapsed? (state/sub-block-collapsed current-block-uuid)
[current-block current-block-uuid {:keys [collapsed? container-id]}]
(let [temp-collapsed? (state/sub-block-collapsed current-block-uuid container-id)
collapsed?' (if (some? temp-collapsed?)
temp-collapsed?
(or collapsed?
@@ -185,7 +185,9 @@
(:block/uuid config))
current-block (db/entity [:block/uuid current-block-uuid])
;; Get query result
collapsed?' (calculate-collapsed? current-block current-block-uuid {:collapsed? false})
collapsed?' (calculate-collapsed? current-block current-block-uuid
{:collapsed? false
:container-id (:container-id config)})
built-in-collapsed? (and collapsed? built-in-query?)
config' (assoc config
:current-block current-block

View File

@@ -1649,9 +1649,10 @@
(when (and (get-in table [:data-fns :add-new-object!]) (or (empty? rows) items-rendered?))
(shui/table-footer (add-new-row (:view-entity option) table)))]]))))
(rum/defc list-view < rum/static
[{:keys [config ref-matched-children-ids disable-virtualized?] :as option} view-entity {:keys [rows]} *scroller-ref]
(let [lazy-item-render (fn [rows idx]
(rum/defcs list-view < rum/static mixins/container-id
[state {:keys [config ref-matched-children-ids disable-virtualized?] :as option} view-entity {:keys [rows]} *scroller-ref]
(let [config (assoc config :container-id (:container-id state))
lazy-item-render (fn [rows idx]
(lazy-item rows idx (assoc option :list-view? true)
(fn [block]
(let [config' (cond->

View File

@@ -49,6 +49,12 @@
(state/<invoke-db-worker :thread-api/get-property-values (state/get-current-repo)
(assoc opts :property-ident property-id))))
(defn <get-bidirectional-properties
[target-id]
(when target-id
(state/<invoke-db-worker :thread-api/get-bidirectional-properties (state/get-current-repo)
{:target-id target-id})))
(defn <get-block
[graph id-uuid-or-name & {:keys [children? include-collapsed-children? skip-transact? skip-refresh? properties]
:or {children? true}

View File

@@ -3452,6 +3452,7 @@
(or (:block/_parent block) (:block.temp/has-children? block))
(integer? (:block-level config))
(>= (:block-level config) (state/get-ref-open-blocks-level)))
(:default-collapsed? config)
(and (or (:view? config) (:popup? config))
(or (ldb/page? block)
(:table-block-title? config))))))

View File

@@ -41,6 +41,8 @@
:logseq.property/exclude-from-graph-view :logseq.property/template-applied-to
:logseq.property/hide-empty-value :logseq.property.class/hide-from-node
:logseq.property/page-tags :logseq.property.class/extends
:logseq.property.class/bidirectional-property-title
:logseq.property.class/enable-bidirectional?
:logseq.property/publishing-public? :logseq.property.user/avatar
:logseq.property.user/email :logseq.property.user/name})

View File

@@ -124,7 +124,7 @@
;; 2. zoom-in view
;; 3. queries
;; 4. references
;; graph => {:block-id bool}
;; graph => {container-id {:block-id bool}}
:ui/collapsed-blocks {}
:ui/sidebar-collapsed-blocks {}
:ui/root-component nil
@@ -1924,23 +1924,37 @@ Similar to re-frame subscriptions"
(->> (sub :sidebar/blocks)
(filter #(= (first %) current-repo)))))
(defn get-current-editor-container-id
[]
@(:editor/container-id @state))
(defn- resolve-container-id
[container-id]
(or container-id (get-current-editor-container-id) :unknown-container))
(defn toggle-collapsed-block!
[block-id]
(let [current-repo (get-current-repo)]
(update-state! [:ui/collapsed-blocks current-repo block-id] not)))
([block-id] (toggle-collapsed-block! block-id nil))
([block-id container-id]
(let [current-repo (get-current-repo)
container-id (resolve-container-id container-id)]
(update-state! [:ui/collapsed-blocks current-repo container-id block-id] not))))
(defn set-collapsed-block!
[block-id value]
(let [current-repo (get-current-repo)]
(set-state! [:ui/collapsed-blocks current-repo block-id] value)))
([block-id value] (set-collapsed-block! block-id value nil))
([block-id value container-id]
(let [current-repo (get-current-repo)
container-id (resolve-container-id container-id)]
(set-state! [:ui/collapsed-blocks current-repo container-id block-id] value))))
(defn sub-block-collapsed
[block-id]
(sub [:ui/collapsed-blocks (get-current-repo) block-id]))
([block-id] (sub-block-collapsed block-id nil))
([block-id container-id]
(sub [:ui/collapsed-blocks (get-current-repo) (resolve-container-id container-id) block-id])))
(defn get-block-collapsed
[block-id]
(get-in @state [:ui/collapsed-blocks (get-current-repo) block-id]))
([block-id] (get-block-collapsed block-id nil))
([block-id container-id]
(get-in @state [:ui/collapsed-blocks (get-current-repo) (resolve-container-id container-id) block-id])))
(defn get-modal-id
[]
@@ -2048,10 +2062,6 @@ Similar to re-frame subscriptions"
id))
(get-next-container-id)))
(defn get-current-editor-container-id
[]
@(:editor/container-id @state))
(comment
(defn remove-container-key!
[key]

View File

@@ -185,7 +185,8 @@
["65.16" {:properties [:logseq.property.asset/external-file-name]}]
["65.17" {:properties [:logseq.property.publish/published-url]}]
["65.18" {:fix deprecated-ensure-graph-uuid}]
["65.19" {:properties [:logseq.property/choice-classes :logseq.property/choice-exclusions]}]])
["65.19" {:properties [:logseq.property/choice-classes :logseq.property/choice-exclusions]}]
["65.20" {:properties [:logseq.property.class/bidirectional-property-title :logseq.property.class/enable-bidirectional?]}]])
(let [[major minor] (last (sort (map (comp (juxt :major :minor) db-schema/parse-schema-version first)
schema-version->updates)))]

View File

@@ -704,6 +704,12 @@
(let [conn (worker-state/get-datascript-conn repo)]
(db-view/get-property-values @conn property-ident option)))
(def-thread-api :thread-api/get-bidirectional-properties
[repo {:keys [target-id]}]
(let [conn (worker-state/get-datascript-conn repo)]
(worker-util/profile "get-bidirectional-properties"
(ldb/get-bidirectional-properties @conn target-id))))
(def-thread-api :thread-api/build-graph
[repo option]
(let [conn (worker-state/get-datascript-conn repo)]

View File

@@ -18,4 +18,4 @@ fom = "fom"
tne = "tne"
Damon = "Damon"
[files]
extend-exclude = ["resources/*", "src/resources/*", "scripts/resources/*", "src/test/fixtures/*", "clj-e2e/resources/*"]
extend-exclude = ["resources/*", "src/resources/*", "scripts/resources/*", "src/test/fixtures/*", "clj-e2e/resources/*", "deps/common/src/logseq/common/plural.cljs"]