123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711 |
- (ns frontend.components.query.builder
- "DSL query builder."
- (:require [clojure.string :as string]
- [frontend.components.select :as component-select]
- [frontend.config :as config]
- [frontend.date :as date]
- [frontend.db :as db]
- [frontend.db-mixins :as db-mixins]
- [frontend.db.async :as db-async]
- [frontend.db.file-based.model :as file-model]
- [frontend.db.model :as db-model]
- [frontend.db.query-dsl :as query-dsl]
- [frontend.handler.editor :as editor-handler]
- [frontend.handler.query.builder :as query-builder]
- [frontend.mixins :as mixins]
- [frontend.util.ref :as ref]
- [frontend.state :as state]
- [frontend.ui :as ui]
- [frontend.util :as util]
- [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.db.sqlite.util :as sqlite-util]
- [logseq.graph-parser.db :as gp-db]
- [logseq.shui.hooks :as hooks]
- [logseq.shui.ui :as shui]
- [promesa.core :as p]
- [rum.core :as rum]))
- (rum/defc page-block-selector
- [*find]
- [:div.filter-item {:on-pointer-down (fn [e] (util/stop-propagation e))}
- (ui/select [{:label "Blocks"
- :value "block"
- :selected (not= @*find :page)}
- {:label "Pages"
- :value "page"
- :selected (= @*find :page)}]
- (fn [e v]
- ;; Prevent opening the current block's editor
- (util/stop e)
- (reset! *find (keyword v))))])
- (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)))
- (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 "Full text search"
- :aria-label "Full text search"
- :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 [auto-focus on-select]}]
- (let [*input-value (::input-value state)]
- [:div.ml-4
- [:input.query-builder-datepicker.form-input.block.sm:text-sm.sm:leading-5
- {:auto-focus (or auto-focus false)
- :data-key (name id)
- :placeholder placeholder
- :aria-label placeholder
- :value (some-> @*input-value (first))
- :on-focus (fn [^js e]
- (js/setTimeout
- #(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 true
- :selected (some-> @*input-value (second))
- :on-select select-handle!}))
- {:id :query-datepicker
- :content-props {:class "p-0"}
- :align :start}) 16))}]]))
- (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
- [:div.font-medium.mt-2 "Between: "]
- (datepicker :start "Start date"
- (merge opts {:auto-focus true
- :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 "End date" opts)]
- [:p.pt-2
- (ui/button "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"}
- "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 [{value :value db-ident :db/ident}]
- (reset! *mode "property-value")
- (reset! *property (if (config/db-based-graph? (state/get-current-repo))
- db-ident
- (keyword value)))))]))
- (rum/defc property-value-select-inner
- < rum/reactive db-mixins/query
- [*property *private-property? *find *tree opts loc values {:keys [db-graph?]}]
- (let [values' (cons {:label "Select all"
- :value "Select all"}
- values)
- find' (rum/react *find)]
- (select values'
- (fn [{:keys [value]}]
- (let [k (cond
- db-graph? (if @*private-property? :private-property :property)
- (= find' :page) :page-property
- :else :property)
- x (if (= value "Select all")
- [k @*property]
- [k @*property value])]
- (reset! *property nil)
- (append-tree! *tree opts loc x))))))
- (rum/defc property-value-select
- [repo *property *private-property? *find *tree opts loc]
- (let [db-graph? (sqlite-util/db-based-graph? repo)
- [values set-values!] (rum/use-state nil)]
- (hooks/use-effect!
- (fn [_property]
- (p/let [result (if db-graph?
- (p/let [result (db-async/<get-property-values @*property)]
- (map (fn [{:keys [label _value]}]
- {:label label
- :value label})
- result))
- (p/let [result (db-async/<file-get-property-values repo @*property)]
- (map (fn [value]
- {:label (str value)
- :value value}) result)))]
- (set-values! result)))
- [@*property])
- (property-value-select-inner *property *private-property? *find *tree opts loc values
- {:db-graph? db-graph?})))
- (rum/defc tags
- [repo *tree opts loc]
- (let [[values set-values!] (rum/use-state nil)
- db-based? (config/db-based-graph? repo)]
- (hooks/use-effect!
- (fn []
- (let [result (db-model/get-all-readable-classes repo {:except-root-class? true})]
- (set-values! result)))
- [])
- (let [items (->> values
- (map :block/title)
- sort)]
- (select items
- (fn [{:keys [value]}]
- (append-tree! *tree opts loc [(if db-based? :tags :page-tags) value]))))))
- (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 *find *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 repo *property *private-property? *find *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 (if (config/db-based-graph? repo)
- (let [values (:property/closed-values (db/entity :logseq.property/priority))]
- (mapv db-property/property-value-content values))
- gp-db/built-in-priorities)
- (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)]))
- (defn- file-based-query-filter-picker
- [state *find *tree loc clause opts]
- (let [*mode (::mode state)
- *property (::property state)
- *private-property? (::private-property? state)
- repo (state/get-current-repo)]
- [:div
- (case @*mode
- "namespace"
- (let [items (sort (map :block/title (file-model/get-all-namespace-parents repo)))]
- (select items
- (fn [{:keys [value]}]
- (append-tree! *tree opts loc [:namespace value]))))
- "tags"
- (tags repo *tree opts loc)
- "property"
- (property-select *mode *property *private-property?)
- "property-value"
- (property-value-select repo *property *private-property? *find *tree opts loc)
- "sample"
- (select (range 1 101)
- (fn [{:keys [value]}]
- (append-tree! *tree opts loc [:sample (util/safe-parse-int value)])))
- "task"
- (select (if (config/db-based-graph? repo)
- (let [values (:property/closed-values (db/entity :logseq.property/status))]
- (mapv db-property/property-value-content values))
- gp-db/built-in-markers)
- (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 (if (config/db-based-graph? repo)
- (let [values (:property/closed-values (db/entity :logseq.property/priority))]
- (mapv db-property/property-value-content values))
- gp-db/built-in-priorities)
- (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])))
- "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 *find *tree loc clause opts]
- (let [*mode (::mode state)
- db-based? (config/db-based-graph? (state/get-current-repo))
- filters (if db-based?
- query-builder/db-based-block-filters
- (if (= :page (rum/react *find))
- query-builder/page-filters
- query-builder/block-filters))
- filters-and-ops (concat filters query-builder/operators)
- operator? #(contains? query-builder/operators-set (keyword %))]
- [:div.query-builder-picker
- (if @*mode
- (when-not (operator? @*mode)
- (if db-based?
- (db-based-query-filter-picker state *find *tree loc clause opts)
- (file-based-query-filter-picker state *find *tree loc clause opts)))
- [:div
- (when-not db-based?
- [:<>
- (when-not @*find
- [:div.flex.flex-row.items-center.p-2.justify-between
- [:div.ml-2 "Find: "]
- (page-block-selector *find)])
- (when-not @*find
- [:hr.m-0])])
- (select
- (map name filters-and-ops)
- (fn [{:keys [value]}]
- (cond
- (= value "all page tags")
- (append-tree! *tree opts loc [:all-page-tags])
- (operator? value)
- (append-tree! *tree opts loc [(keyword value)])
- :else
- (reset! *mode value)))
- {:input-default-placeholder "Add filter/operator"})])]))
- (rum/defc add-filter
- [*find *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 *find *tree loc clause {:toggle-fn #(shui/popup-hide! id)}))
- {:align :start}))}
- (ui/icon "plus" {:size 14})
- (when (= [0] loc) "Filter")))
- (declare clauses-group)
- (defn- dsl-human-output
- [clause]
- (let [f (first clause)]
- (cond
- (string/starts-with? (str f) "?") ; variable
- (str clause)
- (string? clause)
- (str "Search: " clause)
- (= (keyword f) :page-ref)
- (ref/->page-ref (second clause))
- (contains? #{:tags :page-tags} (keyword f))
- (cond
- (string? (second clause))
- (str "#" (second clause))
- (symbol? (second clause))
- (str "#" (str (second clause)))
- :else
- (str "#" (second (second clause))))
- (contains? #{:property :private-property :page-property} (keyword f))
- (str (if (and (config/db-based-graph? (state/get-current-repo))
- (qualified-keyword? (second clause)))
- (:block/title (db/entity (second clause)))
- (some-> (second clause) name))
- ": "
- (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 (if (= k :block/created-at)
- "Created"
- "Updated")
- " " 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)))]
- (str "between: " start " ~ " end))
- (contains? #{:task :priority} (keyword f))
- (str (name f) ": "
- (string/join " | " (rest clause)))
- (contains? #{:page :task :namespace} (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 "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!))}
- "Delete"]
- (when operator?
- [:a {:title "Unwrap this operator"
- :on-click (fn []
- (swap! *tree (fn [q]
- (let [loc' (vec (butlast loc))]
- (query-builder/unwrap-operator q loc'))))
- (shui/popup-hide!))}
- "Unwrap"])
- [:div.font-medium.text-sm "Wrap this filter with: "]
- [: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 "Replace with: "]
- [: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 *find *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)])]
- (if (config/db-based-graph? (state/get-current-repo))
- (editor-handler/save-block! repo (:block/uuid block) q)
- (let [content (string/replace (:block/title block)
- #"\{\{query[^}]+\}\}"
- (util/format "{{query %s}}" q))]
- (editor-handler/save-block! repo (:block/uuid block) content)))))))
- (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))
- 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 *find *tree [0] [])]]))
|