mirror of
https://github.com/logseq/logseq.git
synced 2026-05-16 17:02:34 +00:00
614 lines
24 KiB
Clojure
614 lines
24 KiB
Clojure
(ns frontend.components.query.builder
|
|
"DSL query builder."
|
|
(:require [clojure.string :as string]
|
|
[frontend.components.select :as component-select]
|
|
[frontend.date :as date]
|
|
[frontend.db :as db]
|
|
[frontend.db-mixins :as db-mixins]
|
|
[frontend.db.async :as db-async]
|
|
[frontend.db.model :as db-model]
|
|
[frontend.db.query-dsl :as query-dsl]
|
|
[frontend.context.i18n :refer [t]]
|
|
[frontend.handler.editor :as editor-handler]
|
|
[frontend.handler.query.builder :as query-builder]
|
|
[frontend.mixins :as mixins]
|
|
[frontend.state :as state]
|
|
[frontend.ui :as ui]
|
|
[frontend.util :as util]
|
|
[frontend.util.ref :as ref]
|
|
[logseq.common.util :as common-util]
|
|
[logseq.common.util.page-ref :as page-ref]
|
|
[logseq.db :as ldb]
|
|
[logseq.db.frontend.property :as db-property]
|
|
[logseq.shui.hooks :as hooks]
|
|
[logseq.shui.ui :as shui]
|
|
[promesa.core :as p]
|
|
[rum.core :as rum]))
|
|
|
|
(defn- select
|
|
([items on-chosen]
|
|
(select items on-chosen {}))
|
|
([items on-chosen options]
|
|
(component-select/select (merge
|
|
;; Allow caller to build :items
|
|
{:items (if (map? (first items))
|
|
items
|
|
(map #(hash-map :value %) items))
|
|
:on-chosen on-chosen}
|
|
options))))
|
|
|
|
(defn append-tree!
|
|
[*tree {:keys [toggle-fn toggle?]
|
|
:or {toggle? true}} loc x]
|
|
(swap! *tree #(query-builder/append-element % loc x))
|
|
(when toggle? (toggle-fn)))
|
|
|
|
(defn- filter-label
|
|
[value]
|
|
(case value
|
|
"tags" (t :property.built-in/tags)
|
|
"page reference" (t :query.builder/filter-page-reference-label)
|
|
"property" (t :class.built-in/property)
|
|
"task" (t :class.built-in/task)
|
|
"priority" (t :property.built-in/priority)
|
|
"page" (t :query.builder/filter-page-label)
|
|
"full text search" (t :query.builder/filter-full-text-search-label)
|
|
"between" (t :view.filter/operator-between)
|
|
"sample" (t :query.builder/filter-sample-label)
|
|
"and" (t :query.builder/operator-and-label)
|
|
"or" (t :view.filter/or)
|
|
"not" (t :query.builder/operator-not-label)
|
|
value))
|
|
|
|
(rum/defcs search < (rum/local nil ::input-value)
|
|
(mixins/event-mixin
|
|
(fn [state]
|
|
(mixins/on-key-down
|
|
state
|
|
{;; enter
|
|
13 (fn [state e]
|
|
(let [input-value (get state ::input-value)]
|
|
(when-not (string/blank? @input-value)
|
|
(util/stop e)
|
|
(let [on-submit (first (:rum/args state))]
|
|
(on-submit @input-value))
|
|
(reset! input-value nil))))
|
|
;; escape
|
|
27 (fn [_state _e]
|
|
(let [[_on-submit on-cancel] (:rum/args state)]
|
|
(on-cancel)))})))
|
|
[state _on-submit _on-cancel]
|
|
(let [*input-value (::input-value state)]
|
|
[:input#query-builder-search.form-input.block.sm:text-sm.sm:leading-5
|
|
{:auto-focus true
|
|
:placeholder (t :search/full-text-placeholder)
|
|
:aria-label (t :search/full-text-placeholder)
|
|
:on-change #(reset! *input-value (util/evalue %))}]))
|
|
|
|
(defonce *between-dates (atom {}))
|
|
(rum/defcs datepicker < rum/reactive
|
|
(rum/local nil ::input-value)
|
|
{:will-unmount (fn [state]
|
|
(swap! *between-dates dissoc (first (:rum/args state)))
|
|
state)}
|
|
[state id placeholder {:keys [on-select]}]
|
|
(let [*input-value (::input-value state)]
|
|
(shui/button
|
|
{:variant :secondary
|
|
:size :sm
|
|
:on-click (fn [^js e]
|
|
(shui/popup-show! (.-target e)
|
|
(let [select-handle! (fn [^js d]
|
|
(let [gd (date/js-date->goog-date d)
|
|
journal-date (date/js-date->journal-title gd)]
|
|
(reset! *input-value [journal-date d])
|
|
(swap! *between-dates assoc id journal-date))
|
|
(some-> on-select (apply []))
|
|
(shui/popup-hide!))]
|
|
(ui/single-calendar
|
|
{:initial-focus false
|
|
:selected (some-> @*input-value (second))
|
|
:on-select select-handle!}))
|
|
{:id :query-datepicker
|
|
:content-props {:class "p-0"}
|
|
:align :start}))}
|
|
(or (first @*input-value) placeholder))))
|
|
|
|
(rum/defcs between <
|
|
(rum/local nil ::start)
|
|
(rum/local nil ::end)
|
|
[state {:keys [tree loc] :as opts}]
|
|
[:div.between-date.p-4 {:on-pointer-down (fn [e] (util/stop-propagation e))}
|
|
[:div.flex.flex-row.items-center.gap-2
|
|
(datepicker :start (t :query.builder/between-start-label)
|
|
(merge opts {:on-select (fn []
|
|
(when-let [^js end-input (js/document.querySelector ".query-builder-datepicker[data-key=end]")]
|
|
(when (string/blank? (.-value end-input))
|
|
(.focus end-input))))}))
|
|
"~"
|
|
(datepicker :end (t :query.builder/between-end-label) opts)]
|
|
[:p.pt-2
|
|
(ui/button (t :ui/submit)
|
|
:on-click (fn []
|
|
(let [{:keys [start end]} @*between-dates]
|
|
(when (and start end)
|
|
(let [clause [:between [:page-ref start] [:page-ref end]]]
|
|
(append-tree! tree opts loc clause)
|
|
(reset! *between-dates {}))))))]])
|
|
|
|
(rum/defc property-select
|
|
[*mode *property *private-property?]
|
|
(let [[properties set-properties!] (rum/use-state nil)
|
|
properties (cond->> properties
|
|
(not @*private-property?)
|
|
(remove ldb/built-in?))]
|
|
(hooks/use-effect!
|
|
(fn []
|
|
(p/let [properties (db-async/<get-all-properties {:remove-built-in-property? false
|
|
:remove-non-queryable-built-in-property? true})]
|
|
(set-properties! properties)))
|
|
[])
|
|
[:div.flex.flex-col.gap-1
|
|
[:div.flex.flex-row.justify-between.gap-1.items-center.px-1.pb-1.border-b
|
|
[:label.opacity-50.cursor.select-none.text-sm
|
|
{:for "built-in"}
|
|
(t :query.builder/show-built-in-properties)]
|
|
(shui/checkbox
|
|
{:id "built-in"
|
|
:value @*private-property?
|
|
:on-checked-change #(reset! *private-property? (not @*private-property?))})]
|
|
(select (map #(hash-map :db/ident (:db/ident %)
|
|
:value (:block/title %))
|
|
properties)
|
|
(fn [{db-ident :db/ident}]
|
|
(reset! *mode "property-value")
|
|
(reset! *property db-ident)))]))
|
|
|
|
(rum/defc property-value-select-inner
|
|
< rum/reactive db-mixins/query
|
|
[*property *private-property? *tree opts loc values]
|
|
(let [select-all-label (t :view.table/select-all)
|
|
values' (cons {:label select-all-label
|
|
:value select-all-label}
|
|
(map #(hash-map :value (str (:value %))
|
|
;; Preserve original-value as non-string values like boolean do not display in select
|
|
:original-value (:value %))
|
|
values))]
|
|
(select values'
|
|
(fn [{:keys [value original-value]}]
|
|
(let [k (if @*private-property? :private-property :property)
|
|
x (if (= value select-all-label)
|
|
[k @*property]
|
|
[k @*property original-value])]
|
|
(reset! *property nil)
|
|
(append-tree! *tree opts loc x))))))
|
|
|
|
(rum/defc property-value-select
|
|
[*property *private-property? *tree opts loc]
|
|
(let [[values set-values!] (rum/use-state nil)]
|
|
(hooks/use-effect!
|
|
(fn [_property]
|
|
(p/let [result (p/let [result (db-async/<get-property-values @*property)]
|
|
(map (fn [{:keys [label]}]
|
|
{:label label
|
|
:value label})
|
|
result))]
|
|
(set-values! result)))
|
|
[@*property])
|
|
(property-value-select-inner *property *private-property? *tree opts loc values)))
|
|
|
|
(rum/defc tags
|
|
[repo *tree opts loc]
|
|
(let [[values set-values!] (rum/use-state nil)]
|
|
(hooks/use-effect!
|
|
(fn []
|
|
(let [result (db-model/get-all-readable-classes repo {:except-root-class? true})]
|
|
(set-values! result)))
|
|
[])
|
|
(let [items (->> (sort-by :block/title values)
|
|
(map (fn [block]
|
|
{:label (:block/title block)
|
|
:value (:block/uuid block)})))]
|
|
(select items
|
|
(fn [{:keys [value _label]}]
|
|
(append-tree! *tree opts loc [:tags (str value)]))
|
|
{:extract-fn :label}))))
|
|
|
|
(rum/defc page-search
|
|
[on-chosen]
|
|
(let [[result set-result!] (hooks/use-state nil)
|
|
[loading? set-loading!] (hooks/use-state nil)]
|
|
(hooks/use-effect!
|
|
(fn []
|
|
(set-loading! true)
|
|
(p/let [result (state/<invoke-db-worker :thread-api/get-all-page-titles (state/get-current-repo))]
|
|
(set-result! result)
|
|
(set-loading! false)))
|
|
[])
|
|
(select result on-chosen {:loading? loading?})))
|
|
|
|
(defn- db-based-query-filter-picker
|
|
[state *tree loc clause opts]
|
|
(let [*mode (::mode state)
|
|
*property (::property state)
|
|
*private-property? (::private-property? state)
|
|
repo (state/get-current-repo)]
|
|
[:div
|
|
(case @*mode
|
|
"property"
|
|
(property-select *mode *property *private-property?)
|
|
|
|
"property-value"
|
|
(property-value-select *property *private-property? *tree opts loc)
|
|
|
|
"sample"
|
|
(select (range 1 101)
|
|
(fn [{:keys [value]}]
|
|
(append-tree! *tree opts loc [:sample (util/safe-parse-int value)])))
|
|
|
|
"tags"
|
|
(tags repo *tree opts loc)
|
|
|
|
"task"
|
|
(let [items (let [values (:property/closed-values (db/entity :logseq.property/status))]
|
|
(mapv db-property/property-value-content values))]
|
|
(select items
|
|
(constantly nil)
|
|
{:multiple-choices? true
|
|
;; Need the existing choices later to improve the UX
|
|
:selected-choices #{}
|
|
:extract-chosen-fn :value
|
|
:prompt-key :select/default-select-multiple
|
|
:close-modal? false
|
|
:on-apply (fn [choices]
|
|
(when (seq choices)
|
|
(append-tree! *tree opts loc (vec (cons :task choices)))))}))
|
|
|
|
"priority"
|
|
(select
|
|
(let [values (:property/closed-values (db/entity :logseq.property/priority))]
|
|
(mapv db-property/property-value-content values))
|
|
(constantly nil)
|
|
{:multiple-choices? true
|
|
:selected-choices #{}
|
|
:extract-chosen-fn :value
|
|
:prompt-key :select/default-select-multiple
|
|
:close-modal? false
|
|
:on-apply (fn [choices]
|
|
(when (seq choices)
|
|
(append-tree! *tree opts loc (vec (cons :priority choices)))))})
|
|
|
|
"page"
|
|
(page-search (fn [{:keys [value]}]
|
|
(append-tree! *tree opts loc [:page value])))
|
|
|
|
;; TODO: replace with node reference
|
|
"page reference"
|
|
|
|
(page-search (fn [{:keys [value]}]
|
|
(append-tree! *tree opts loc [:page-ref value])))
|
|
|
|
"full text search"
|
|
(search (fn [v] (append-tree! *tree opts loc v))
|
|
(:toggle-fn opts))
|
|
|
|
"between"
|
|
(between (merge opts
|
|
{:tree *tree
|
|
:loc loc
|
|
:clause clause}))
|
|
|
|
nil)]))
|
|
|
|
(rum/defcs picker < rum/reactive
|
|
{:will-mount (fn [state]
|
|
(state/clear-selection!)
|
|
state)}
|
|
(rum/local nil ::mode) ; pick mode
|
|
(rum/local nil ::property)
|
|
(rum/local false ::private-property?)
|
|
[state *tree loc clause opts]
|
|
(let [*mode (::mode state)
|
|
filters query-builder/db-based-block-filters
|
|
filters-and-ops (concat filters query-builder/operators)
|
|
operator? #(contains? query-builder/operators-set (keyword %))
|
|
select-items (mapv (fn [value]
|
|
{:value value
|
|
:label (filter-label value)})
|
|
(map name filters-and-ops))]
|
|
[:div.query-builder-picker
|
|
(if @*mode
|
|
(when-not (operator? @*mode)
|
|
(db-based-query-filter-picker state *tree loc clause opts))
|
|
[:div
|
|
(select
|
|
select-items
|
|
(fn [{:keys [value]}]
|
|
(cond
|
|
(operator? value)
|
|
(append-tree! *tree opts loc [(keyword value)])
|
|
|
|
:else
|
|
(reset! *mode value)))
|
|
{:extract-fn (fn [{:keys [label value]}]
|
|
(if label
|
|
(str label " " value)
|
|
value))
|
|
:input-default-placeholder (t :query.builder/add-filter-or-operator-placeholder)})])]))
|
|
|
|
(rum/defc add-filter
|
|
[*tree loc clause]
|
|
(shui/button
|
|
{:class "jtrigger !px-1 h-6 add-filter text-muted-foreground"
|
|
:size :sm
|
|
:variant :outline
|
|
:on-pointer-down util/stop-propagation
|
|
:on-click (fn [^js e]
|
|
(shui/popup-show! (.-target e)
|
|
(fn [{:keys [id]}]
|
|
(picker *tree loc clause {:toggle-fn #(shui/popup-hide! id)}))
|
|
{:align :start}))}
|
|
(ui/icon "plus" {:size 14})
|
|
(when (= [0] loc) (t :query.builder/filter))))
|
|
|
|
(declare clauses-group)
|
|
|
|
(defn- uuid->page-title
|
|
[s]
|
|
(if (and (string? s) (common-util/uuid-string? s))
|
|
(:block/title (db/entity [:block/uuid (uuid s)]))
|
|
s))
|
|
|
|
(defn- dsl-human-output
|
|
[clause]
|
|
(let [f (first clause)]
|
|
(cond
|
|
(string/starts-with? (str f) "?") ; variable
|
|
(str clause)
|
|
|
|
(string? clause)
|
|
(t :query.builder/search-label clause)
|
|
|
|
(= (keyword f) :page-ref)
|
|
(ref/->page-ref (uuid->page-title (second clause)))
|
|
|
|
(contains? #{:tags} (keyword f))
|
|
(cond
|
|
(string? (second clause))
|
|
(str "#" (uuid->page-title (second clause)))
|
|
(symbol? (second clause))
|
|
(str "#" (uuid->page-title (str (second clause))))
|
|
:else
|
|
(str "#" (uuid->page-title (second (second clause)))))
|
|
|
|
(contains? #{:property :private-property} (keyword f))
|
|
(str (if (qualified-keyword? (second clause))
|
|
(:block/title (db/entity (second clause)))
|
|
(some-> (second clause) name))
|
|
": "
|
|
(uuid->page-title
|
|
(cond
|
|
(and (vector? (last clause)) (= :page-ref (first (last clause))))
|
|
(second (last clause))
|
|
|
|
(= 2 (count clause))
|
|
"ALL"
|
|
|
|
:else
|
|
(last clause))))
|
|
|
|
;; between timestamp start (optional end)
|
|
(and (= (keyword f) :between) (query-dsl/get-timestamp-property clause))
|
|
(let [k (query-dsl/get-timestamp-property clause)
|
|
[_ _property start end] clause
|
|
start (if (or (keyword? start)
|
|
(symbol? start))
|
|
(name start)
|
|
(second start))
|
|
end (if (or (keyword? end)
|
|
(symbol? end))
|
|
(name end)
|
|
(second end))]
|
|
(str (cond
|
|
(= k :block/created-at)
|
|
(t :query.builder/created-label)
|
|
(= k :block/updated-at)
|
|
(t :query.builder/updated-label)
|
|
:else
|
|
(or (:block/title (db/entity k)) (name k)))
|
|
" " start
|
|
(when end
|
|
(str " ~ " end))))
|
|
|
|
;; between journal start end
|
|
(= (keyword f) :between)
|
|
(let [start (if (or (keyword? (second clause))
|
|
(symbol? (second clause)))
|
|
(name (second clause))
|
|
(second (second clause)))
|
|
end (if (or (keyword? (last clause))
|
|
(symbol? (last clause)))
|
|
(name (last clause))
|
|
(second (last clause)))]
|
|
(t :query.builder/between-journal-label (uuid->page-title start) (uuid->page-title end)))
|
|
|
|
(contains? #{:task :priority} (keyword f))
|
|
(str (name f) ": "
|
|
(string/join " | " (rest clause)))
|
|
|
|
(contains? #{:page :task} (keyword f))
|
|
(str (name f) ": " (if (vector? (second clause))
|
|
(second (second clause))
|
|
(second clause)))
|
|
|
|
(= 2 (count clause))
|
|
(str (name f) ": " (second clause))
|
|
|
|
:else
|
|
(str (query-builder/->dsl clause)))))
|
|
|
|
(rum/defc clause-inner
|
|
[*tree loc clause & {:keys [operator?]}]
|
|
(let [popup [:div.p-4.flex.flex-col.gap-2
|
|
[:a {:title (t :ui/delete)
|
|
:on-click (fn []
|
|
(swap! *tree (fn [q]
|
|
(let [loc' (if operator? (vec (butlast loc)) loc)]
|
|
(query-builder/remove-element q loc'))))
|
|
(shui/popup-hide!))}
|
|
(t :ui/delete)]
|
|
|
|
(when operator?
|
|
[:a {:title (t :query.builder/unwrap-operator)
|
|
:on-click (fn []
|
|
(swap! *tree (fn [q]
|
|
(let [loc' (vec (butlast loc))]
|
|
(query-builder/unwrap-operator q loc'))))
|
|
(shui/popup-hide!))}
|
|
(t :query.builder/unwrap-operator)])
|
|
|
|
[:div.font-medium.text-sm (t :query.builder/wrap-filter-with-label)]
|
|
[:div.flex.flex-row.gap-2
|
|
(for [op query-builder/operators]
|
|
(ui/button (string/upper-case (name op))
|
|
:intent "logseq"
|
|
:small? true
|
|
:on-click (fn []
|
|
(swap! *tree (fn [q]
|
|
(let [loc' (if operator? (vec (butlast loc)) loc)]
|
|
(query-builder/wrap-operator q loc' op))))
|
|
(shui/popup-hide!))))]
|
|
|
|
(when operator?
|
|
[:div
|
|
[:div.font-medium.text-sm (t :query.builder/replace-with-label)]
|
|
[:div.flex.flex-row.gap-2
|
|
(for [op (remove #{(keyword (string/lower-case clause))} query-builder/operators)]
|
|
(ui/button (string/upper-case (name op))
|
|
:intent "logseq"
|
|
:small? true
|
|
:on-click (fn []
|
|
(swap! *tree (fn [q]
|
|
(query-builder/replace-element q loc op)))
|
|
(shui/popup-hide!))))]])]]
|
|
(if operator?
|
|
[:a.flex.text-sm.query-clause {:on-click #(shui/popup-show! (.-target %) popup {:align :start})}
|
|
clause]
|
|
|
|
[:div.flex.flex-row.items-center.gap-2.px-1.rounded.border.query-clause-btn
|
|
[:a.flex.query-clause {:on-click #(shui/popup-show! (.-target %) popup {:align :start})}
|
|
(dsl-human-output clause)]])))
|
|
|
|
(rum/defc clause
|
|
[*tree *find loc clauses]
|
|
(when (seq clauses)
|
|
[:div.query-builder-clause
|
|
(let [operator (first clauses)
|
|
kind (keyword operator)]
|
|
(if (query-builder/operators-set kind)
|
|
[:div.operator-clause.flex.flex-row.items-center {:data-level (count loc)}
|
|
[:div.clause-bracket "("]
|
|
(clauses-group *tree *find (conj loc 0) kind (rest clauses))
|
|
[:div.clause-bracket ")"]]
|
|
(clause-inner *tree loc clauses)))]))
|
|
|
|
(rum/defc clauses-group
|
|
[*tree *find loc kind clauses]
|
|
(let [parens? (and (= loc [0]) (or (not= kind :and) (> (count clauses) 1)))]
|
|
[:div.clauses-group
|
|
(when parens? [:div.clause-bracket "("])
|
|
(when-not (and (= loc [0])
|
|
(= kind :and)
|
|
(<= (count clauses) 1))
|
|
(clause-inner *tree loc
|
|
(string/upper-case (name kind))
|
|
:operator? true))
|
|
|
|
(map-indexed (fn [i item]
|
|
(clause *tree *find (update loc (dec (count loc)) #(+ % i 1)) item))
|
|
clauses)
|
|
|
|
(when parens? [:div.clause-bracket ")"])
|
|
|
|
(when (not= loc [0])
|
|
(add-filter *tree loc []))]))
|
|
|
|
(rum/defc clause-tree < rum/reactive
|
|
[*tree *find]
|
|
(let [tree (rum/react *tree)
|
|
kind ((set query-builder/operators) (first tree))
|
|
[kind' clauses] (if kind
|
|
[kind (rest tree)]
|
|
[:and [@tree]])]
|
|
(clauses-group *tree *find [0] kind' clauses)))
|
|
|
|
(defn sanitize-q
|
|
[q-str]
|
|
(if (string/blank? q-str)
|
|
""
|
|
(if (or (common-util/wrapped-by-parens? q-str)
|
|
(common-util/wrapped-by-quotes? q-str)
|
|
(page-ref/page-ref? q-str)
|
|
(string/starts-with? q-str "[?"))
|
|
q-str
|
|
(str "\"" q-str "\""))))
|
|
|
|
(defn- get-q
|
|
[block]
|
|
(sanitize-q (or (:file-version/query-macro-title block)
|
|
(:block/title block)
|
|
"")))
|
|
|
|
(rum/defcs builder <
|
|
(rum/local nil ::find)
|
|
{:init (fn [state]
|
|
(let [block (first (:rum/args state))
|
|
q-str (get-q block)
|
|
query (common-util/safe-read-string
|
|
query-dsl/custom-readers
|
|
(query-dsl/pre-transform-query q-str))
|
|
query' (cond
|
|
(contains? #{'and 'or 'not} (first query))
|
|
query
|
|
|
|
query
|
|
[:and query]
|
|
|
|
:else
|
|
[:and])
|
|
tree (query-builder/from-dsl query')
|
|
*tree (atom tree)]
|
|
(add-watch *tree :updated (fn [_ _ _old _new]
|
|
(when block
|
|
(let [q (if (= [:and] @*tree)
|
|
""
|
|
(let [result (query-builder/->dsl @*tree)]
|
|
(if (string? result)
|
|
(util/format "\"%s\"" result)
|
|
(str result))))
|
|
repo (state/get-current-repo)
|
|
block (db/entity [:block/uuid (:block/uuid block)])]
|
|
(editor-handler/save-block! repo (:block/uuid block) q)))))
|
|
(assoc state ::tree *tree)))
|
|
:will-mount (fn [state]
|
|
(let [q-str (get-q (first (:rum/args state)))
|
|
blocks-query? (:blocks? (query-dsl/parse-query q-str (db/get-db)))
|
|
find-mode (cond
|
|
blocks-query?
|
|
:block
|
|
(false? blocks-query?)
|
|
:page
|
|
:else
|
|
nil)]
|
|
(when find-mode (reset! (::find state) find-mode))
|
|
state))}
|
|
[state _block _option]
|
|
(let [*find (::find state)
|
|
*tree (::tree state)]
|
|
[:div.cp__query-builder
|
|
[:div.cp__query-builder-filter
|
|
(when (and (seq @*tree)
|
|
(not= @*tree [:and]))
|
|
(clause-tree *tree *find))
|
|
(add-filter *tree [0] [])]]))
|