瀏覽代碼

feat: simple query builder (#8774)

Simple query builder

---------

Co-authored-by: Gabriel Horner <[email protected]>
Co-authored-by: charlie <[email protected]>
Tienson Qin 2 年之前
父節點
當前提交
db6fc6b7ee
共有 37 個文件被更改,包括 1269 次插入301 次删除
  1. 1 0
      .clj-kondo/config.edn
  2. 12 2
      deps/db/src/logseq/db/default.cljs
  3. 1 0
      deps/db/src/logseq/db/schema.cljs
  4. 8 6
      deps/graph-parser/src/logseq/graph_parser/util.cljs
  5. 7 0
      resources/css/common.css
  6. 1 1
      scripts/src/logseq/tasks/lang.clj
  7. 5 1
      src/main/frontend/commands.cljs
  8. 242 186
      src/main/frontend/components/block.cljs
  9. 6 0
      src/main/frontend/components/block.css
  10. 1 1
      src/main/frontend/components/datetime.cljs
  11. 1 1
      src/main/frontend/components/page.cljs
  12. 2 1
      src/main/frontend/components/plugins.cljs
  13. 1 1
      src/main/frontend/components/plugins_settings.cljs
  14. 463 0
      src/main/frontend/components/query/builder.cljs
  15. 46 0
      src/main/frontend/components/query/builder.css
  16. 9 3
      src/main/frontend/components/query_table.cljs
  17. 37 20
      src/main/frontend/components/select.cljs
  18. 0 2
      src/main/frontend/components/svg.cljs
  19. 2 1
      src/main/frontend/components/theme.css
  20. 13 2
      src/main/frontend/db/model.cljs
  21. 63 24
      src/main/frontend/db/query_dsl.cljs
  22. 1 1
      src/main/frontend/db/query_react.cljs
  23. 7 12
      src/main/frontend/db/react.cljs
  24. 1 0
      src/main/frontend/dicts.cljc
  25. 9 3
      src/main/frontend/extensions/video/youtube.cljs
  26. 7 2
      src/main/frontend/handler/command_palette.cljs
  27. 16 0
      src/main/frontend/handler/editor.cljs
  28. 197 0
      src/main/frontend/handler/query/builder.cljs
  29. 1 3
      src/main/frontend/handler/ui.cljs
  30. 1 1
      src/main/frontend/modules/shortcut/core.cljs
  31. 8 4
      src/main/frontend/search.cljs
  32. 0 17
      src/main/frontend/state.cljs
  33. 7 6
      src/main/frontend/ui.cljs
  34. 4 0
      src/main/frontend/ui.css
  35. 7 0
      src/main/frontend/util.cljc
  36. 44 0
      src/test/frontend/db/query_dsl_test.cljs
  37. 38 0
      src/test/frontend/handler/query/builder_test.cljs

+ 1 - 0
.clj-kondo/config.edn

@@ -68,6 +68,7 @@
              frontend.handler.page page-handler
              frontend.handler.plugin plugin-handler
              frontend.handler.plugin-config plugin-config-handler
+             frontend.handler.query.builder query-builder
              frontend.handler.repo repo-handler
              frontend.handler.repo-config repo-config-handler
              frontend.handler.route route-handler

+ 12 - 2
deps/db/src/logseq/db/default.cljs

@@ -1,9 +1,19 @@
 (ns logseq.db.default
   "Provides fns for seeding default data in a logseq db"
-  (:require [clojure.string :as string]))
+  (:require [clojure.string :as string]
+            [clojure.set :as set]))
+
+(defonce built-in-markers
+  ["NOW" "LATER" "DOING" "DONE" "CANCELED" "CANCELLED" "IN-PROGRESS" "TODO" "WAIT" "WAITING"])
+
+(defonce built-in-priorities
+  ["A" "B" "C"])
 
 (defonce built-in-pages-names
-  #{"NOW" "LATER" "DOING" "DONE" "CANCELED" "CANCELLED" "IN-PROGRESS" "TODO" "WAIT" "WAITING" "A" "B" "C" "Favorites" "Contents" "card"})
+  (set/union
+   (set built-in-markers)
+   (set built-in-priorities)
+   #{"Favorites" "Contents" "card"}))
 
 (def built-in-pages
   (mapv (fn [p]

+ 1 - 0
deps/db/src/logseq/db/schema.cljs

@@ -118,6 +118,7 @@
     :block/properties
     :block/properties-order
     :block/properties-text-values
+    :block/macros
     :block/invalid-properties
     :block/created-at
     :block/updated-at

+ 8 - 6
deps/graph-parser/src/logseq/graph_parser/util.cljs

@@ -254,12 +254,14 @@
     (legacy-title-parsing file-name-body)))
 
 (defn safe-read-string
-  [content]
-  (try
-    (reader/read-string content)
-    (catch :default e
-      (log/error :parse/read-string-failed e)
-      {})))
+  ([content]
+   (safe-read-string {} content))
+  ([opts content]
+   (try
+     (reader/read-string opts content)
+     (catch :default e
+       (log/error :parse/read-string-failed e)
+       {}))))
 
 ;; Copied from Medley
 ;; https://github.com/weavejester/medley/blob/d1e00337cf6c0843fb6547aadf9ad78d981bfae5/src/medley/core.cljc#L22

+ 7 - 0
resources/css/common.css

@@ -915,3 +915,10 @@ html.is-mobile {
     margin-top: 5px;
   }
 }
+
+@layer base {
+    .ls-grid-cols {
+        @apply grid grid-flow-col auto-cols-max;
+        place-items: center;
+    }
+}

+ 1 - 1
scripts/src/logseq/tasks/lang.clj

@@ -110,7 +110,7 @@
              :page/make-private :page/make-public]
    "(t (name" [] ;; shortcuts related
    "(t (dh/decorate-namespace" [] ;; shortcuts related
-   "(t prompt-key" [:select/default-prompt :select.graph/prompt]
+   "(t prompt-key" [:select/default-prompt :select/default-select-multiple :select.graph/prompt]
    ;; All args to ui/make-confirm-modal are not keywords
    "(t title" []
    "(t subtitle" [:asset/physical-delete]})

+ 5 - 1
src/main/frontend/commands.cljs

@@ -268,7 +268,8 @@
 
     ;; advanced
 
-    [["Query" [[:editor/input "{{query }}" {:backward-pos 2}]] query-doc]
+    [["Query" [[:editor/input "{{query }}" {:backward-pos 2}]
+               [:editor/exit]] query-doc]
      ["Zotero" (zotero-steps) "Import Zotero journal article"]
      ["Query table function" [[:editor/input "{{function }}" {:backward-pos 2}]] "Create a query table function"]
      ["Calculator" [[:editor/input "```calc\n\n```" {:backward-pos 4}]
@@ -667,6 +668,9 @@
   (when-let [input-file (gdom/getElement "upload-file")]
     (.click input-file)))
 
+(defmethod handle-step :editor/exit [[_]]
+  (state/clear-edit!))
+
 (defmethod handle-step :default [[type & _args]]
   (prn "No handler for step: " type))
 

+ 242 - 186
src/main/frontend/components/block.cljs

@@ -17,6 +17,7 @@
             [frontend.components.macro :as macro]
             [frontend.components.plugins :as plugins]
             [frontend.components.query-table :as query-table]
+            [frontend.components.query.builder :as query-builder-component]
             [frontend.components.svg :as svg]
             [frontend.config :as config]
             [frontend.context.i18n :refer [t]]
@@ -25,7 +26,6 @@
             [frontend.db-mixins :as db-mixins]
             [frontend.db.model :as model]
             [frontend.db.query-dsl :as query-dsl]
-            [frontend.db.react :as react]
             [frontend.db.utils :as db-utils]
             [frontend.extensions.highlight :as highlight]
             [frontend.extensions.latex :as latex]
@@ -529,14 +529,16 @@
              (state/get-left-sidebar-open?))
     (ui-handler/close-left-sidebar!)))
 
-(rum/defc page-inner
+(rum/defcs page-inner <
+  (rum/local false ::mouse-down?)
   "The inner div of page reference component
 
    page-name-in-block is the overridable name of the page (legacy)
 
    All page-names are sanitized except page-name-in-block"
-  [config page-name-in-block page-name redirect-page-name page-entity contents-page? children html-export? label whiteboard-page?]
-  (let [tag? (:tag? config)
+  [state config page-name-in-block page-name redirect-page-name page-entity contents-page? children html-export? label whiteboard-page?]
+  (let [*mouse-down? (::mouse-down? state)
+        tag? (:tag? config)
         config (assoc config :whiteboard-page? whiteboard-page?)
         untitled? (model/untitled-page? page-name)]
     [:a
@@ -548,7 +550,11 @@
       :data-ref page-name
       :draggable true
       :on-drag-start (fn [e] (editor-handler/block->data-transfer! page-name e))
-      :on-mouse-up (fn [e] (open-page-ref e page-name redirect-page-name page-name-in-block contents-page? whiteboard-page?))
+      :on-mouse-down (fn [_e] (reset! *mouse-down? true))
+      :on-mouse-up (fn [e]
+                     (when @*mouse-down?
+                       (open-page-ref e page-name redirect-page-name page-name-in-block contents-page? whiteboard-page?)
+                       (reset! *mouse-down? false)))
       :on-key-up (fn [e] (when (and e (= (.-key e) "Enter"))
                            (open-page-ref e page-name redirect-page-name page-name-in-block contents-page? whiteboard-page?)))}
 
@@ -1216,14 +1222,10 @@
   [:div.dsl-query.pr-3.sm:pr-0
    (let [query (->> (string/join ", " arguments)
                     (string/trim))]
-     (when-not (string/blank? query)
-       (custom-query (assoc config :dsl-query? true)
-                     {:title (ui/tippy {:html commands/query-doc
-                                        :interactive true
-                                        :in-editor?  true}
-                                       [:span.font-medium.px-2.py-1.query-title.text-sm.rounded-md.shadow-xs
-                                        (str "Query: " query)])
-                      :query query})))])
+     (custom-query (assoc config :dsl-query? true)
+                   {:title (rum/with-key (query-builder-component/builder query config)
+                             query)
+                    :query query}))])
 
 (defn- macro-function-cp
   [config arguments]
@@ -2151,11 +2153,12 @@
 (defn- block-content-on-mouse-down
   [e block block-id content edit-input-id]
   (when-not (> (count content) (state/block-content-max-length (state/get-current-repo)))
-    (.stopPropagation e)
     (let [target (gobj/get e "target")
           button (gobj/get e "buttons")
           shift? (gobj/get e "shiftKey")
-          meta? (util/meta-key? e)]
+          meta? (util/meta-key? e)
+          forbidden-edit? (target-forbidden-edit? target)]
+      (when-not forbidden-edit? (.stopPropagation e))
       (if (and meta?
                (not (state/get-edit-input-id))
                (not (dom/has-class? target "page-ref"))
@@ -2166,7 +2169,7 @@
           (when block-id
             (state/set-selection-start-block! block-id)))
         (when (contains? #{1 0} button)
-          (when-not (target-forbidden-edit? target)
+          (when-not forbidden-edit?
             (cond
               (and shift? (state/get-selection-start-block-or-first))
               (do
@@ -2863,7 +2866,14 @@
                                         (select-keys b2 compare-keys))
                                   (not= (select-keys (first (:rum/args old-state)) config-compare-keys)
                                         (select-keys (first (:rum/args new-state)) config-compare-keys)))]
-                      (boolean result)))}
+                      (boolean result)))
+   :will-unmount (fn [state]
+                   ;; restore root block's collapsed state
+                   (let [[config block] (:rum/args state)
+                         block-id (:block/uuid block)]
+                     (when (root-block? config block)
+                       (state/set-collapsed-block! block-id nil)))
+                   state)}
   [state config block]
   (let [repo (state/get-current-repo)
         ref? (:ref? config)
@@ -3035,53 +3045,50 @@
       (boolean (some #(= % title) (map :title queries))))))
 
 (defn- trigger-custom-query!
-  [state]
-  (let [[config query] (:rum/args state)
+  [state *query-error]
+  (let [[config query _query-result] (:rum/args state)
         repo (state/get-current-repo)
         result-atom (or (:query-atom state) (atom nil))
         current-block-uuid (or (:block/uuid (:block config))
                                (:block/uuid config))
-        [full-text-search? query-atom] (if (:dsl-query? config)
-                                         (let [q (:query query)
-                                               form (safe-read-string q false)]
-                                           (cond
-                                             ;; Searches like 'foo' or 'foo bar' come back as symbols
-                                             ;; and are meant to go directly to full text search
-                                             (and (util/electron?) (symbol? form)) ; full-text search
-                                             [true
-                                              (p/let [blocks (search/block-search repo (string/trim (str form)) {:limit 30})]
-                                                (when (seq blocks)
-                                                  (let [result (db/pull-many (state/get-current-repo) '[*] (map (fn [b] [:block/uuid (uuid (:block/uuid b))]) blocks))]
-                                                    (reset! result-atom result))))]
-
-                                             (symbol? form)
-                                             [false (atom nil)]
-
-                                             :else
-                                             [false (query-dsl/query (state/get-current-repo) q)]))
-                                         [false (db/custom-query query {:current-block-uuid current-block-uuid})])
-        query-atom (if (instance? Atom query-atom)
-                     query-atom
-                     result-atom)]
-    (assoc state
-           :query-atom query-atom
-           :full-text-search? full-text-search?)))
-
-(defn- clear-custom-query!
-  [dsl? query]
-  (let [query (if dsl? (:query query) query)]
-    (state/remove-custom-query-component! query)
-    (db/remove-custom-query! (state/get-current-repo) query)))
+        _ (reset! *query-error nil)
+        query-atom (try
+                     (cond
+                       (:dsl-query? config)
+                       (let [q (:query query)
+                             form (safe-read-string q false)]
+                         (cond
+                           ;; Searches like 'foo' or 'foo bar' come back as symbols
+                           ;; and are meant to go directly to full text search
+                           (and (util/electron?) (symbol? form)) ; full-text search
+                           (p/let [blocks (search/block-search repo (string/trim (str form)) {:limit 30})]
+                             (when (seq blocks)
+                               (let [result (db/pull-many (state/get-current-repo) '[*] (map (fn [b] [:block/uuid (uuid (:block/uuid b))]) blocks))]
+                                 (reset! result-atom result))))
+
+                           (symbol? form)
+                           (atom nil)
+
+                           :else
+                           (query-dsl/query (state/get-current-repo) q)))
+
+                       :else
+                       (db/custom-query query {:current-block-uuid current-block-uuid}))
+                     (catch :default e
+                       (reset! *query-error e)
+                       (atom nil)))]
+    (if (instance? Atom query-atom)
+      query-atom
+      result-atom)))
 
 (rum/defc query-refresh-button
-  [state query-time {:keys [on-mouse-down]}]
+  [query-time {:keys [on-mouse-down full-text-search?]}]
   (ui/tippy
    {:html  [:div
             [:p
-             (when (and query-time (> query-time 80))
-               [:span (str "This query takes " (int query-time) "ms to finish, it's a bit slow so that auto refresh is disabled.")])
-             (when (:full-text-search? state)
-               [:span "Full-text search results will not be refreshed automatically."])]
+             (if full-text-search?
+               [:span "Full-text search results will not be refreshed automatically."]
+               [:span (str "This query takes " (int query-time) "ms to finish, it's a bit slow so that auto refresh is disabled.")])]
             [:p
              "Click the refresh button instead if you want to see the latest result."]]
     :interactive     true
@@ -3089,163 +3096,212 @@
                                   {:enabled           true
                                    :boundariesElement "viewport"}}}
     :arrow true}
-   [:a.control.fade-link.ml-1.inline-flex
-    {:style {:margin-top 7}
-     :on-mouse-down on-mouse-down}
+   [:a.fade-link.flex
+    {:on-mouse-down on-mouse-down}
     (ui/icon "refresh" {:style {:font-size 20}})]))
 
-(rum/defcs ^:large-vars/cleanup-todo custom-query* < rum/reactive
-  {:will-mount trigger-custom-query!
-   :did-mount (fn [state]
-                (when-let [query (last (:rum/args state))]
-                  (state/add-custom-query-component! query (:rum/react-component state)))
-                state)
-   :will-unmount (fn [state]
-                   (when-let [query (last (:rum/args state))]
-                     (clear-custom-query! (:dsl-query? (first (:rum/args state)))
-                                          query))
-                   state)}
-  [state config {:keys [title query view collapsed? children? breadcrumb-show? table-view?] :as q}]
-  (let [dsl-query? (:dsl-query? config)
-        query-atom (:query-atom state)
-        query-time (or (react/get-query-time query)
-                       (react/get-query-time q))
-        view-fn (if (keyword? view) (get-in (state/sub-config) [:query/views view]) view)
-        current-block-uuid (or (:block/uuid (:block config))
-                               (:block/uuid config))
-        current-block (db/entity [:block/uuid current-block-uuid])
+(rum/defcs custom-query-inner < rum/reactive db-mixins/query
+  [state config {:keys [query children? breadcrumb-show?] :as q}
+   {:keys [query-result-atom
+           query-error-atom
+           current-block
+           current-block-uuid
+           table?
+           dsl-query?
+           page-list?
+           built-in-query?
+           view-f]}]
+  (let [*query-error query-error-atom
+        query-atom (if built-in-query? query-result-atom (trigger-custom-query! state *query-error))
+        query-result (and query-atom (rum/react query-atom))
         ;; exclude the current one, otherwise it'll loop forever
         remove-blocks (if current-block-uuid [current-block-uuid] nil)
-        query-result (and query-atom (rum/react query-atom))
-        table? (or table-view?
-                   (get-in current-block [:block/properties :query-table])
-                   (and (string? query) (string/ends-with? (string/trim query) "table")))
         transformed-query-result (when query-result
                                    (db/custom-query-result-transform query-result remove-blocks q))
         not-grouped-by-page? (or table?
                                  (boolean (:result-transform q))
                                  (and (string? query) (string/includes? query "(by-page false)")))
         result (if (and (:block/uuid (first transformed-query-result)) (not not-grouped-by-page?))
-                 (db-utils/group-by-page transformed-query-result)
+                 (let [result (db-utils/group-by-page transformed-query-result)]
+                   (if (map? result)
+                     (dissoc result nil)
+                     result))
                  transformed-query-result)
+        _ (when (and query-result-atom (not built-in-query?))
+            (reset! query-result-atom (util/safe-with-meta result (meta @query-atom))))
         _ (when-let [query-result (:query-result config)]
             (let [result (remove (fn [b] (some? (get-in b [:block/properties :template]))) result)]
               (reset! query-result result)))
-        view-f (and view-fn (sci/eval-string (pr-str view-fn)))
         only-blocks? (:block/uuid (first result))
         blocks-grouped-by-page? (and (seq result)
                                      (not not-grouped-by-page?)
                                      (coll? (first result))
                                      (:block/name (ffirst result))
                                      (:block/uuid (first (second (first result))))
-                                     true)
+                                     true)]
+    (if @*query-error
+      (do
+        (log/error :exception @*query-error)
+        [:div.warning.my-1 "Query failed: "
+         [:p (.-message @*query-error)]])
+      [:div.custom-query-results
+       (cond
+         (and (seq result) view-f)
+         (let [result (try
+                        (sci/call-fn view-f result)
+                        (catch :default error
+                          (log/error :custom-view-failed {:error error
+                                                          :result result})
+                          [:div "Custom view failed: "
+                           (str error)]))]
+           (util/hiccup-keywordize result))
+
+         page-list?
+         (query-table/result-table config current-block result {:page? true} map-inline page-cp ->elem inline-text)
+
+         table?
+         (query-table/result-table config current-block result {:page? false} map-inline page-cp ->elem inline-text)
+
+         (and (seq result) (or only-blocks? blocks-grouped-by-page?))
+         (->hiccup result (cond-> (assoc config
+                                         :custom-query? true
+                                         :dsl-query? dsl-query?
+                                         :query query
+                                         :breadcrumb-show? (if (some? breadcrumb-show?)
+                                                             breadcrumb-show?
+                                                             true)
+                                         :group-by-page? blocks-grouped-by-page?
+                                         :ref? true)
+                            children?
+                            (assoc :ref? true))
+                   {:style {:margin-top "0.25rem"
+                            :margin-left "0.25rem"}})
+
+         (seq result)
+         (let [result (->>
+                       (for [record result]
+                         (if (map? record)
+                           (str (util/pp-str record) "\n")
+                           record))
+                       (remove nil?))]
+           (when (seq result)
+             [:ul
+              (for [item result]
+                [:li (str item)])]))
+
+         (or (string/blank? query)
+             (= query "(and)"))
+         nil
+
+         :else
+         [:div.text-sm.mt-2.opacity-90 "No matched result"])])))
+
+(rum/defc query-title
+  [config title]
+  [:div.custom-query-title.flex.justify-between.w-full
+   [:span.title-text (cond
+                       (vector? title) title
+                       (string? title) (inline-text config
+                                                    (get-in config [:block :block/format] :markdown)
+                                                    title)
+                       :else title)]])
+
+(rum/defcs ^:large-vars/cleanup-todo custom-query* < rum/reactive
+  (rum/local nil ::query-result)
+  {:init (fn [state] (assoc state :query-error (atom nil)))}
+  [state config {:keys [title query view collapsed? table-view?] :as q}]
+  (let [*query-error (:query-error state)
         built-in? (built-in-custom-query? title)
+        *query-result (if built-in?
+                        (trigger-custom-query! state *query-error)
+                        (::query-result state))
+        result (rum/react *query-result)
+        dsl-query? (:dsl-query? config)
+        current-block-uuid (or (:block/uuid (:block config))
+                               (:block/uuid config))
+        current-block (db/entity [:block/uuid current-block-uuid])
+        temp-collapsed? (state/sub-collapsed current-block-uuid)
+        collapsed?' (if (some? temp-collapsed?)
+                      temp-collapsed?
+                      (or
+                       collapsed?
+                       (:block/collapsed? current-block)))
+        table? (or table-view?
+                   (get-in current-block [:block/properties :query-table])
+                   (and (string? query) (string/ends-with? (string/trim query) "table")))
+        query-time (:query-time (meta @*query-result))
+        view-fn (if (keyword? view) (get-in (state/sub-config) [:query/views view]) view)
+        view-f (and view-fn (sci/eval-string (pr-str view-fn)))
         page-list? (and (seq result)
-                        (:block/name (first result)))
-        nested-query? (:custom-query? config)]
-    (if nested-query?
+                        (some? (:block/name (first result))))
+        dsl-page-query? (and dsl-query?
+                             (false? (:blocks? (query-dsl/parse-query query))))
+        full-text-search? (and dsl-query?
+                               (util/electron?)
+                               (symbol? (safe-read-string query false)))]
+    (if (:custom-query? config)
       [:code (if dsl-query?
                (util/format "{{query %s}}" query)
                "{{query hidden}}")]
       (when-not (and built-in? (empty? result))
-        [:div.custom-query.mt-4 (get config :attr {})
-         (ui/foldable
-          [:div.custom-query-title.flex.justify-between.w-full
-           [:div.flex.items-center
-            [:span.title-text (cond
-                                (vector? title) title
-                                (string? title) (inline-text config
-                                                             (get-in config [:block :block/format] :markdown)
-                                                             title)
-                                :else title)]
-           [:span.opacity-60.text-sm.ml-2.results-count
-            (str (count result) " results")]]
-
-           ;;insert an "edit" button in the query view
-           [:div.flex.items-center
-            (when-not built-in?
-              [:a.opacity-70.hover:opacity-100.svg-small.inline
-               {:on-mouse-down (fn [e]
-                                 (util/stop e)
-                                 (editor-handler/edit-block! current-block :max (:block/uuid current-block)))}
-               svg/edit])
-
-            (when (or (:full-text-search? state)
-                      (and query-time (> query-time 80)))
-              (query-refresh-button state query-time
-                                    {:on-mouse-down (fn [e]
-                                                      (util/stop e)
-                                                      (trigger-custom-query! state))}))]]
-          (fn []
-            [:div
-             (when (and current-block (not view-f) (nil? table-view?))
-               [:div.flex.flex-row.align-items.mt-2 {:on-mouse-down (fn [e] (util/stop e))}
-                (when-not page-list?
-                  [:div.flex.flex-row
-                   [:div.mx-2 [:span.text-sm "Table view"]]
-                   [:div {:style {:margin-top 5}}
-                    (ui/toggle table?
-                               (fn []
-                                 (editor-handler/set-block-property! current-block-uuid
-                                                                     "query-table"
-                                                                     (not table?)))
-                               true)]])
-
-                [:a.mx-2.block.fade-link
-                 {:on-click (fn []
-                              (let [all-keys (query-table/get-keys result page-list?)]
-                                (state/pub-event! [:modal/set-query-properties current-block all-keys])))}
-                 [:span.table-query-properties
-                  [:span.text-sm.mr-1 "Set properties"]
-                  svg/settings-sm]]])
-             (cond
-               (and (seq result) view-f)
-               (let [result (try
-                              (sci/call-fn view-f result)
-                              (catch :default error
-                                (log/error :custom-view-failed {:error error
-                                                                :result result})
-                                [:div "Custom view failed: "
-                                 (str error)]))]
-                 (util/hiccup-keywordize result))
-
-               page-list?
-               (query-table/result-table config current-block result {:page? true} map-inline page-cp ->elem inline-text)
-
-               table?
-               (query-table/result-table config current-block result {:page? false} map-inline page-cp ->elem inline-text)
-
-               (and (seq result) (or only-blocks? blocks-grouped-by-page?))
-               (->hiccup result (cond-> (assoc config
-                                               :custom-query? true
-                                               :dsl-query? dsl-query?
-                                               :query query
-                                               :breadcrumb-show? (if (some? breadcrumb-show?)
-                                                                   breadcrumb-show?
-                                                                   true)
-                                               :group-by-page? blocks-grouped-by-page?
-                                               :ref? true)
-                                  children?
-                                  (assoc :ref? true))
-                         {:style {:margin-top "0.25rem"
-                                  :margin-left "0.25rem"}})
-
-               (seq result)
-               (let [result (->>
-                             (for [record result]
-                               (if (map? record)
-                                 (str (util/pp-str record) "\n")
-                                 record))
-                             (remove nil?))]
-                 [:pre result])
-
-               :else
-               [:div.text-sm.mt-2.ml-2.font-medium.opacity-50 "Empty"])])
-          {:default-collapsed? collapsed?
-           :title-trigger? true
-           :on-mouse-down (fn [collapsed?]
-                            (when collapsed?
-                              (clear-custom-query! dsl-query? q)))})]))))
+        (let [opts {:query-result-atom *query-result
+                    :query-error-atom *query-error
+                    :current-block current-block
+                    :dsl-query? dsl-query?
+                    :current-block-uuid current-block-uuid
+                    :table? table?
+                    :view-f view-f
+                    :page-list? page-list?
+                    :built-in-query? built-in?}]
+          [:div.custom-query (get config :attr {})
+           (when-not built-in?
+             [:div.th
+              [:div.flex.flex-1.flex-row
+               (ui/icon "search" {:size 14})
+               [:div.ml-1 (str "Live query" (when dsl-page-query? " for pages"))]]
+              (when-not collapsed?'
+                [:div.flex.flex-row.items-center.fade-in
+                 (when (> (count result) 0)
+                   [:span.results-count
+                    (str (count result) (if (> (count result) 1) " results" " result"))])
+
+                 (when (and current-block (not view-f) (nil? table-view?) (not page-list?))
+                   (if table?
+                     [:a.flex.ml-1.fade-link {:title "Switch to list view"
+                                              :on-click (fn [] (editor-handler/set-block-property! current-block-uuid
+                                                                                                   "query-table"
+                                                                                                   false))}
+                      (ui/icon "list" {:style {:font-size 20}})]
+                     [:a.flex.ml-1.fade-link {:title "Switch to table view"
+                                              :on-click (fn [] (editor-handler/set-block-property! current-block-uuid
+                                                                                                   "query-table"
+                                                                                                   true))}
+                      (ui/icon "table" {:style {:font-size 20}})]))
+
+                 [:a.flex.ml-1.fade-link
+                  {:title "Setting properties"
+                   :on-click (fn []
+                               (let [all-keys (query-table/get-keys result page-list?)]
+                                 (state/pub-event! [:modal/set-query-properties current-block all-keys])))}
+                  (ui/icon "settings" {:style {:font-size 20}})]
+
+                 [:div.ml-1
+                  (when (or full-text-search?
+                            (and query-time (> query-time 50)))
+                    (query-refresh-button query-time {:full-text-search? full-text-search?
+                                                      :on-mouse-down (fn [e]
+                                                                       (util/stop e)
+                                                                       (trigger-custom-query! state *query-error))}))]])])
+           (if built-in?
+             (ui/foldable
+              (query-title config title)
+              (fn []
+                (custom-query-inner config q opts))
+              {})
+             [:div.bd
+              (query-title config title)
+              (when-not collapsed?'
+                (custom-query-inner config q opts))])])))))
 
 (rum/defc custom-query
   [config q]

+ 6 - 0
src/main/frontend/components/block.css

@@ -538,6 +538,12 @@ a:hover > .bullet-container {
   }
 }
 
+.ls-block .custom-query {
+  > .th {
+    @apply flex flex-row flex-1 items-center justify-between my-1 text-xs opacity-90;
+  }
+}
+
 /* copied from https://github.com/drdogbot7/tailwindcss-responsive-embed */
 .embed-responsive {
   position: relative;

+ 1 - 1
src/main/frontend/components/datetime.cljs

@@ -62,7 +62,7 @@
           {:label "w"}
           {:label "m"}
           {:label "y"}])
-        (fn [value]
+        (fn [_e value]
           (swap! *timestamp assoc-in [:repeater :duration] value))
         nil)
 

+ 1 - 1
src/main/frontend/components/page.cljs

@@ -560,7 +560,7 @@
               ;;         item))
               ;;     [{:label "gForce"}
               ;;      {:label "dagre"}])
-              ;;    (fn [value]
+              ;;    (fn [_e value]
               ;;      (set-setting! :layout value))
               ;;    "graph-layout")]
               [:div.flex.items-center.justify-between.mb-2

+ 2 - 1
src/main/frontend/components/plugins.cljs

@@ -391,7 +391,8 @@
                        {:label "Direct" :value "direct" :selected (= type "direct")}
                        {:label "HTTP"   :value "http"   :selected (= type "http")}
                        {:label "SOCKS5" :value "socks5" :selected (= type "socks5")}]
-                      #(set-opts! (assoc opts :type % :protocol %)))]]
+             (fn [_e value]
+               (set-opts! (assoc opts :type value :protocol value))))]]
       [:p.flex
        [:label.pr-4
         {:class (if disabled? "opacity-50" nil)}

+ 1 - 1
src/main/frontend/components/plugins_settings.cljs

@@ -65,7 +65,7 @@
          :radio (ui/radio-list options #(update-setting! key %) nil)
          :checkbox (ui/checkbox-list options #(update-setting! key %) nil)
          ;; select
-         (ui/select options #(update-setting! key %) nil))
+         (ui/select options (fn [_ value ] (update-setting! key value)) nil))
        ]]]))
 
 (rum/defc render-item-object

+ 463 - 0
src/main/frontend/components/query/builder.cljs

@@ -0,0 +1,463 @@
+(ns frontend.components.query.builder
+  "DSL query builder."
+  (:require [frontend.ui :as ui]
+            [frontend.date :as date]
+            [frontend.db :as db]
+            [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.components.select :as component-select]
+            [frontend.state :as state]
+            [frontend.util :as util]
+            [frontend.search :as search]
+            [frontend.mixins :as mixins]
+            [logseq.db.default :as db-default]
+            [rum.core :as rum]
+            [clojure.string :as string]
+            [logseq.graph-parser.util :as gp-util]
+            [logseq.graph-parser.util.page-ref :as page-ref]))
+
+(rum/defc page-block-selector
+  [*find]
+  [:div.filter-item {:on-mouse-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
+                             {:items items
+                              :on-chosen on-chosen
+                              :extract-fn nil}
+                             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 *shown-datepicker (atom nil))
+(defonce *between-dates (atom {}))
+(rum/defcs datepicker < rum/reactive
+  (rum/local nil ::input-value)
+  {:init (fn [state]
+           (when (:auto-focus (last (:rum/args state)))
+             (reset! *shown-datepicker (first (:rum/args state))))
+           state)
+   :will-unmount (fn [state]
+                   (swap! *between-dates dissoc (first (:rum/args state)))
+                   state)}
+  [state id placeholder {:keys [auto-focus]}]
+  (let [*input-value (::input-value state)
+        show? (= id (rum/react *shown-datepicker))]
+    [:div.ml-4
+     [:input.query-builder-datepicker.form-input.block.sm:text-sm.sm:leading-5
+      {:auto-focus (or auto-focus false)
+       :placeholder placeholder
+       :aria-label placeholder
+       :value @*input-value
+       :on-click #(reset! *shown-datepicker id)}]
+     (when show?
+       (ui/datepicker nil {:on-change (fn [_e date]
+                                        (let [journal-date (date/journal-name date)]
+                                          (reset! *input-value journal-date)
+                                          (reset! *shown-datepicker nil)
+                                          (swap! *between-dates assoc id journal-date)))}))]))
+
+(rum/defcs between <
+  (rum/local nil ::start)
+  (rum/local nil ::end)
+  [state {:keys [tree loc] :as opts}]
+  [:div.between-date {:on-mouse-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}))
+    (datepicker :end "End date" opts)]
+   (ui/button "Submit"
+     :on-click (fn []
+                 (let [{:keys [start end]} @*between-dates]
+                   (when (and start end)
+                     (let [clause [:between start end]]
+                       (append-tree! tree opts loc clause)
+                       (reset! *between-dates {}))))))])
+
+(defn- query-filter-picker
+  [state *find *tree loc clause opts]
+  (let [*mode (::mode state)
+        *property (::property state)
+        repo (state/get-current-repo)]
+    [:div
+     (case @*mode
+       "namespace"
+       (let [items (sort (db-model/get-all-namespace-parents repo))]
+         (select items
+                 (fn [value]
+                   (append-tree! *tree opts loc [:namespace value]))))
+
+       "tags"
+       (let [items (->> (db-model/get-all-tagged-pages repo)
+                        (map second)
+                        sort)]
+         (select items
+                 (fn [value]
+                   (append-tree! *tree opts loc [:page-tags value]))))
+
+       "property"
+       (let [properties (search/get-all-properties)]
+         (select properties
+                 (fn [value]
+                   (reset! *mode "property-value")
+                   (reset! *property (keyword value)))))
+
+       "property-value"
+       (let [values (cons "Select all" (db-model/get-property-values @*property))]
+         (select values
+                 (fn [value]
+                   (let [x (if (= value "Select all")
+                             [(if (= @*find :page) :page-property :property) @*property]
+                             [(if (= @*find :page) :page-property :property) @*property value])]
+                     (reset! *property nil)
+                     (append-tree! *tree opts loc x)))))
+
+       "sample"
+       (select (range 1 101)
+               (fn [value]
+                 (append-tree! *tree opts loc [:sample (util/safe-parse-int value)])))
+
+       "task"
+       (select db-default/built-in-markers
+               (fn [value]
+                 (when (seq value)
+                   (append-tree! *tree opts loc (vec (cons :task value)))))
+               {:multiple-choices? true
+                ;; Need the existing choices later to improve the UX
+                :selected-choices #{}
+                :prompt-key :select/default-select-multiple
+                :close-modal? false})
+
+       "priority"
+       (select db-default/built-in-priorities
+               (fn [value]
+                 (when (seq value)
+                   (append-tree! *tree opts loc (vec (cons :priority value)))))
+               {:multiple-choices? true
+                :selected-choices #{}
+                :prompt-key :select/default-select-multiple
+                :close-modal? false})
+
+       "page"
+       (let [pages (sort (db-model/get-all-page-original-names repo))]
+         (select pages
+                 (fn [value]
+                   (append-tree! *tree opts loc [:page value]))))
+
+       "page reference"
+       (let [pages (sort (db-model/get-all-page-original-names repo))]
+         (select pages
+                 (fn [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 <
+  {:will-mount (fn [state]
+                 (state/clear-selection!)
+                 state)}
+  (rum/local nil ::mode)                ; pick mode
+  (rum/local nil ::property)
+  [state *find *tree loc clause opts]
+  (let [*mode (::mode state)
+        filters (if (= :page @*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)
+         (query-filter-picker state *find *tree loc clause opts))
+       [:div
+        (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 [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]
+  (ui/dropdown
+   (fn [{:keys [toggle-fn]}]
+     [:a.flex.add-filter {:title "Add clause"
+                          :on-click toggle-fn}
+      (ui/icon "plus" {:style {:font-size 20}})])
+   (fn [{:keys [toggle-fn]}]
+     (picker *find *tree loc clause {:toggle-fn toggle-fn}))
+   {:modal-class (util/hiccup->class
+                  "origin-top-right.absolute.left-0.mt-2.ml-2.rounded-md.shadow-lg")}))
+
+(declare clauses-group)
+
+(defn- dsl-human-output
+  [clause]
+  (let [f (first clause)]
+    (cond
+      (string? clause)
+      (str "search: " clause)
+
+      (= (keyword f) :page-ref)
+      (page-ref/->page-ref (second clause))
+
+      (= (keyword f) :page-tags)
+      (if (string? (second clause))
+        (str "#" (second clause))
+        (str "#" (second (second clause))))
+
+      (contains? #{:property :page-property} (keyword f))
+      (str (name (second clause)) ": "
+           (cond
+             (and (vector? (last clause)) (= :page-ref (first (last clause))))
+             (second (last clause))
+
+             (= 2 (count clause))
+             "ALL"
+
+             :else
+             (last clause)))
+
+      (= (keyword f) :between)
+      (str "between: " (second (second clause)) " - " (second (last clause)))
+
+      (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?]}]
+  (ui/dropdown
+   (fn [{:keys [toggle-fn]}]
+     (if operator?
+       [:a.flex.text-sm.query-clause {:on-click toggle-fn}
+        clause]
+
+       [:div.flex.flex-row.items-center.gap-2.p-1.rounded.border
+        [:a.flex.query-clause {:on-click toggle-fn}
+         (dsl-human-output clause)]]))
+   (fn [{:keys [toggle-fn]}]
+     [: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'))))
+                       (toggle-fn))}
+       "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'))))
+                         (toggle-fn))}
+         "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))))
+                       (toggle-fn))))]
+
+      (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)))
+                          (toggle-fn))))]])])
+   {:modal-class (util/hiccup->class
+                  "origin-top-right.absolute.left-0.mt-2.ml-2.rounded-md.shadow-lg.w-64")}))
+
+(rum/defc clause
+  [*tree *find loc clause]
+  (when (seq clause)
+    [:div.query-builder-clause
+     (let [kind (keyword (first clause))]
+       (if (query-builder/operators-set kind)
+         [:div.operator-clause.flex.flex-row.items-center {:data-level (count loc)}
+          [:div.text-4xl.mr-1.font-thin "("]
+          (clauses-group *tree *find (conj loc 0) kind (rest clause))
+          [:div.text-4xl.ml-1.font-thin ")"]]
+         (clause-inner *tree loc clause)))]))
+
+(rum/defc clauses-group
+  [*tree *find loc kind clauses]
+  (let [parens? (and (= loc [0])
+                     (> (count clauses) 1))]
+    [:div.clauses-group
+     (when parens? [:div.text-4xl.mr-1.font-thin "("])
+     (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.text-4xl.ml-1.font-thin ")"])
+
+     (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)))
+
+(rum/defcs builder <
+  (rum/local nil ::find)
+  {:init (fn [state]
+           (let [q-str (first (:rum/args state))
+                 query (gp-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)
+                 config (last (:rum/args state))]
+             (add-watch *tree :updated (fn [_ _ _old _new]
+                                         (when-let [block (:block config)]
+                                           (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/pull [:block/uuid (:block/uuid block)])]
+                                             (when block
+                                               (let [content (string/replace (:block/content block)
+                                                                             (util/format "{{query %s" q-str)
+                                                                             (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 (first (:rum/args state))
+                       parsed-query (query-dsl/parse-query q-str)
+                       blocks-query? (:blocks? parsed-query)
+                       find-mode (cond
+                                   blocks-query?
+                                   :block
+                                   (false? blocks-query?)
+                                   :page
+                                   :else
+                                   nil)]
+                   (when find-mode (reset! (::find state) find-mode))
+                   state))}
+  [state _query _config]
+  (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] [])]]))

+ 46 - 0
src/main/frontend/components/query/builder.css

@@ -0,0 +1,46 @@
+.cp__query-builder {
+    @apply grid auto-rows-max gap-2;
+
+    &-filter {
+        @apply flex flex-row items-center gap-1;
+    }
+
+    .cp__select-main {
+        width: fit-content;
+        margin: 0;
+    }
+
+    .between-date {
+        min-width: 36em;
+        padding: 1em;
+    }
+
+    .cp__select .input-wrap {
+        height: auto;
+        min-width: 14em;
+    }
+
+    .cp__select .input-wrap input {
+        border: none;
+    }
+
+    .cp__select-input {
+        padding: 0.5em 1em;
+    }
+
+    .clauses-group {
+        @apply flex flex-row gap-1 flex-wrap items-center text-sm;
+    }
+
+    a.query-clause, a.add-filter {
+        color: var(--ls-primary-text-color);
+    }
+
+    a.query-clause:hover, a.add-filter {
+        color: var(--ls-secondary-text-color);
+    }
+
+    .filter-item select {
+        border: none;
+    }
+}

+ 9 - 3
src/main/frontend/components/query_table.cljs

@@ -100,6 +100,7 @@
 (defn- get-columns [current-block result {:keys [page?]}]
   (let [query-properties (some-> (get-in current-block [:block/properties :query-properties] "")
                                  (common-handler/safe-read-string "Parsing query properties failed"))
+        query-properties (if page? (remove #{:block} query-properties) query-properties)
         columns (if (seq query-properties)
                   query-properties
                   (get-keys result page?))
@@ -114,10 +115,12 @@
 ;; Table rows are called items
 (rum/defcs result-table < rum/reactive
   (rum/local false ::select?)
+  (rum/local false ::mouse-down?)
   [state config current-block result {:keys [page?]} map-inline page-cp ->elem inline-text]
   (when current-block
     (let [result (tree/filter-top-level-blocks result)
           select? (get state ::select?)
+          *mouse-down? (::mouse-down? state)
           ;; remove templates
           result (remove (fn [b] (some? (get-in b [:block/properties :template]))) result)
           result (if page? result (attach-clock-property result))
@@ -173,14 +176,17 @@
                               [:string (or (get-in item [:block/properties-text-values column])
                                            ;; Fallback to property relationships for page blocks
                                            (get-in item [:block/properties column]))])]
-                  [:td.whitespace-nowrap {:on-mouse-down (fn [] (reset! select? false))
+                  [:td.whitespace-nowrap {:on-mouse-down (fn []
+                                                           (reset! *mouse-down? true)
+                                                           (reset! select? false))
                                           :on-mouse-move (fn [] (reset! select? true))
                                           :on-mouse-up (fn []
-                                                         (when-not @select?
+                                                         (when (and @*mouse-down? (not @select?))
                                                            (state/sidebar-add-block!
                                                             (state/get-current-repo)
                                                             (:db/id item)
-                                                            :block-ref)))}
+                                                            :block-ref)
+                                                           (reset! *mouse-down? false)))}
                    (when value
                      (if (= :element (first value))
                        (second value)

+ 37 - 20
src/main/frontend/components/select.cljs

@@ -16,36 +16,45 @@
             [frontend.handler.repo :as repo-handler]
             [reitit.frontend.easy :as rfe]))
 
-(rum/defc render-item
-  [result chosen?]
-  (if (map? result)
-    (let [{:keys [id value]} result]
-      [:div.inline-grid.grid-cols-4.gap-x-4.w-full
-       {:class (when chosen? "chosen")}
-       [:span.col-span-3 value]
-       [:div.col-span-1.justify-end.tip.flex
-        (when id
-          [:code.opacity-20.bg-transparent id])]])
-    [:div.inline-grid.grid-cols-4.gap-x-4.w-full
-     {:class (when chosen? "chosen")}
-     [:span.col-span-3 result]]))
+(rum/defc render-item < rum/reactive
+  [result chosen? multiple-choices? *selected-choices]
+  (let [value (if (map? result) (:value result) result)
+        selected-choices (rum/react *selected-choices)]
+    [:div.flex.flex-row.justify-between.w-full {:class (when chosen? "chosen")}
+     [:span
+      (when multiple-choices? (ui/checkbox {:checked (selected-choices value)
+                                            :style {:margin-right 4}
+                                            :on-click (fn [e]
+                                                        (.preventDefault e))}))
+      value]
+     (when (and (map? result) (:id result))
+       [:div.tip.flex
+        [:code.opacity-20.bg-transparent (:id result)]])]))
 
-(rum/defcs select <
+(rum/defcs select < rum/reactive
   (shortcut/disable-all-shortcuts)
   (rum/local "" ::input)
-  {:will-unmount (fn [state]
+  {:init (fn [state]
+           (assoc state ::selected-choices
+                  (atom (set (:selected-choices (first (:rum/args state)))))))
+   :will-unmount (fn [state]
                    (state/set-state! [:ui/open-select] nil)
+                   (let [{:keys [multiple-choices? on-chosen]} (first (:rum/args state))]
+                     (when (and multiple-choices? on-chosen)
+                       (on-chosen @(::selected-choices state))))
                    state)}
   [state {:keys [items limit on-chosen empty-placeholder
                  prompt-key input-default-placeholder close-modal?
                  extract-fn host-opts on-input input-opts
-                 item-cp transform-fn tap-*input-val]
+                 item-cp transform-fn tap-*input-val
+                 multiple-choices? _selected-choices]
           :or {limit 100
                prompt-key :select/default-prompt
                empty-placeholder (fn [_t] [:div])
                close-modal? true
                extract-fn :value}}]
-  (let [input (::input state)]
+  (let [input (::input state)
+        *selected-choices (::selected-choices state)]
     (when (fn? tap-*input-val)
       (tap-*input-val input))
     [:div.cp__select
@@ -68,11 +77,19 @@
          (fn? transform-fn)
          (transform-fn @input))
 
-       {:item-render       (or item-cp render-item)
+       {:item-render       (or item-cp (fn [result chosen?]
+                                         (render-item result chosen? multiple-choices? *selected-choices)))
         :class             "cp__select-results"
         :on-chosen         (fn [x]
-                             (when close-modal? (state/close-modal!))
-                             (on-chosen x))
+                             (reset! input "")
+                             (if multiple-choices?
+                               (if (@*selected-choices x)
+                                 (swap! *selected-choices disj x)
+                                 (swap! *selected-choices conj x))
+                               (do
+                                 (when close-modal? (state/close-modal!))
+                                 (when on-chosen
+                                   (on-chosen (if multiple-choices? @*selected-choices x))))))
         :empty-placeholder (empty-placeholder t)})]]))
 
 (defn select-config

+ 0 - 2
src/main/frontend/components/svg.cljs

@@ -57,8 +57,6 @@
 
 (def close (hero-icon "M6 18L18 6M6 6L18 18"))
 (def folder (hero-icon "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"))
-(def settings-sm [:svg {:viewBox "0 0 20 20", :fill "currentColor", :height "20", :width "20"}
-                  [:path {:fill-rule "evenodd", :d "M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z", :clip-rule "evenodd"}]])
 
 (def external-link
   [:svg {:fill   "none", :view-box "0 0 24 24", :height "21", :width "21"

+ 2 - 1
src/main/frontend/components/theme.css

@@ -48,6 +48,7 @@ html {
   border: none;
   position: relative;
   top: -1px;
+  margin-right: 2px;
 }
 
 .form-checkbox:hover {
@@ -167,4 +168,4 @@ main.ls-fold-button-on-right {
       padding-left: 10px;
     }
   }
-}
+}

+ 13 - 2
src/main/frontend/db/model.cljs

@@ -104,7 +104,12 @@
          [?page :block/name ?page-name]
          [?page :block/namespace ?e]
          [?e :block/name ?parent]]
-       (conn/get-db repo)))
+    (conn/get-db repo)))
+
+(defn get-all-namespace-parents
+  [repo]
+  (->> (get-all-namespace-relation repo)
+       (map second)))
 
 (defn get-pages
   [repo]
@@ -122,7 +127,13 @@
    '[:find [(pull ?page [*]) ...]
      :where
      [?page :block/name]]
-   (conn/get-db repo)))
+    (conn/get-db repo)))
+
+(defn get-all-page-original-names
+  [repo]
+  (let [db (conn/get-db repo)]
+    (->> (d/datoms db :avet :block/original-name)
+         (map :v))))
 
 (defn get-pages-with-file
   "Return full file entity for calling file renaming"

+ 63 - 24
src/main/frontend/db/query_dsl.cljs

@@ -14,15 +14,19 @@
             [frontend.template :as template]
             [logseq.graph-parser.text :as text]
             [logseq.graph-parser.util.page-ref :as page-ref]
+            [logseq.graph-parser.util :as gp-util]
             [frontend.util.text :as text-util]
             [frontend.util :as util]))
 
 
 ;; Query fields:
 
+;; Operators:
 ;; and
 ;; or
 ;; not
+
+;; Filters:
 ;; between
 ;;   Example: (between -7d +7d)
 ;;            (between created-at -1d today)
@@ -32,16 +36,17 @@
 ;; task (block)
 ;; priority (block)
 ;; page
+;; sample
+;; full-text-search ""
+
+;; namespace
 ;; page-property (page)
 ;; page-tags (page)
 ;; all-page-tags
-;; project (block, TBD)
 
 ;; Sort by (field, asc/desc):
 ;; (sort-by created-at asc)
 
-;; (between -7d +7d)
-
 ;; Time helpers
 ;; ============
 (defn- ->journal-day-int [input]
@@ -443,23 +448,32 @@ Some bindings in this fn:
 ;; parse fns
 ;; =========
 
-(defn- pre-transform
+(defonce tag-placeholder "~~~tag-placeholder~~~")
+(defn pre-transform
   [s]
-  (let [quoted-page-ref (str "\"" page-ref/left-brackets "$1" page-ref/right-brackets "\"")]
-    (some-> s
-            (string/replace page-ref/page-ref-re quoted-page-ref)
-            (string/replace text-util/between-re
-                            (fn [[_ x]]
-                              (->> (string/split x #" ")
-                                   (remove string/blank?)
-                                   (map (fn [x]
-                                          (if (or (contains? #{"+" "-"} (first x))
-                                                  (and (util/safe-re-find #"\d" (first x))
-                                                       (some #(string/ends-with? x %) ["y" "m" "d" "h" "min"])))
-                                            (keyword (name x))
-                                            x)))
-                                   (string/join " ")
-                                   (util/format "(between %s)")))))))
+  (if (gp-util/wrapped-by-quotes? s)
+    s
+    (let [quoted-page-ref (fn [matches]
+                            (let [match' (string/replace (second matches) "#" tag-placeholder)]
+                              (str "\"" page-ref/left-brackets match' page-ref/right-brackets "\"")))]
+      (some-> s
+              (string/replace page-ref/page-ref-re quoted-page-ref)
+              (string/replace text-util/between-re
+                              (fn [[_ x]]
+                                (->> (string/split x #" ")
+                                     (remove string/blank?)
+                                     (map (fn [x]
+                                            (if (or (contains? #{"+" "-"} (first x))
+                                                    (and (util/safe-re-find #"\d" (first x))
+                                                         (some #(string/ends-with? x %) ["y" "m" "d" "h" "min"])))
+                                              (keyword (name x))
+                                              x)))
+                                     (string/join " ")
+                                     (util/format "(between %s)"))))
+              (string/replace #"\"[^\"]+\"" (fn [s] (string/replace s "#" tag-placeholder)))
+              (string/replace " #" " #tag ")
+              (string/replace #"^#" "#tag ")
+              (string/replace tag-placeholder "#")))))
 
 (defn- add-bindings!
   [form q]
@@ -499,17 +513,33 @@ Some bindings in this fn:
       :else
       q)))
 
+(defn simplify-query
+  [query]
+  (if (string? query)
+    query
+    (walk/postwalk
+     (fn [f]
+       (if (and
+            (coll? f)
+            (contains? #{'and 'or} (first f))
+            (= 2 (count f)))
+         (second f)
+         f))
+     query)))
+
+(def custom-readers {:readers {'tag (fn [x] (page-ref/->page-ref x))}})
 (defn parse
   [s]
   (when (and (string? s)
              (not (string/blank? s)))
     (let [s (if (= \# (first s)) (page-ref/->page-ref (subs s 1)) s)
-          form (some-> s
-                       (pre-transform)
-                       (reader/read-string))
+          form (some->> s
+                        (pre-transform)
+                        (reader/read-string custom-readers))
           sort-by (atom nil)
           blocks? (atom nil)
           sample (atom nil)
+          form (simplify-query form)
           {result :query rules :rules}
           (when form (build-query form {:sort-by sort-by
                                         :blocks? blocks?
@@ -545,12 +575,21 @@ Some bindings in this fn:
       (apply conj q where)
       (conj q where))))
 
+(defn parse-query
+  [q]
+  (let [q' (template/resolve-dynamic-template! q)]
+    (parse q')))
+
+(defn pre-transform-query
+  [q]
+  (let [q' (template/resolve-dynamic-template! q)]
+    (pre-transform q')))
+
 (defn query
   "Runs a dsl query with query as a string. Primary use is from '{{query }}'"
   [repo query-string]
   (when (and (string? query-string) (not= "\"\"" query-string))
-    (let [query-string' (template/resolve-dynamic-template! query-string)
-          {:keys [query rules sort-by blocks? sample]} (parse query-string')]
+    (let [{:keys [query rules sort-by blocks? sample]} (parse-query query-string)]
       (when-let [query' (some-> query (query-wrapper {:blocks? blocks?}))]
         (let [sort-by (or sort-by identity)
               random-samples (if @sample

+ 1 - 1
src/main/frontend/db/query_react.cljs

@@ -103,7 +103,7 @@
           resolved-inputs (mapv #(resolve-input db % resolve-with) inputs)
           inputs (cond-> resolved-inputs
                          rules
-                         (conj rules))
+                   (conj rules))
           k [:custom (or (:query-string query') query') inputs]]
       (pprint "inputs (post-resolution):" resolved-inputs)
       (pprint "query-opts:" query-opts)

+ 7 - 12
src/main/frontend/db/react.cljs

@@ -81,11 +81,6 @@
     (let [new-result' (f @result-atom)]
       (reset! result-atom new-result'))))
 
-(defn get-query-time
-  [q]
-  (let [k [(state/get-current-repo) :custom q]]
-    (get-in @query-state [k :query-time])))
-
 (defn kv
   [key value]
   {:db/id -1
@@ -113,12 +108,12 @@
   [k query time inputs result-atom transform-fn query-fn inputs-fn]
   (let [time' (int (util/safe-parse-float time))] ;; for robustness. `time` should already be float
     (swap! query-state assoc k {:query query
-                               :query-time time'
-                               :inputs inputs
-                               :result result-atom
-                               :transform-fn transform-fn
-                               :query-fn query-fn
-                               :inputs-fn inputs-fn}))
+                                :query-time time'
+                                :inputs inputs
+                                :result result-atom
+                                :transform-fn transform-fn
+                                :query-fn query-fn
+                                :inputs-fn inputs-fn}))
   result-atom)
 
 (defn remove-q!
@@ -184,7 +179,7 @@
                                             transform-fn))
                 result-atom (or result-atom (atom nil))]
             ;; Don't notify watches now
-            (set! (.-state result-atom) result)
+            (set! (.-state result-atom) (util/safe-with-meta result {:query-time time}))
             (if disable-reactive?
               result-atom
               (add-q! k query time inputs result-atom transform-fn query-fn inputs-fn))))))))

+ 1 - 0
src/main/frontend/dicts.cljc

@@ -339,6 +339,7 @@
 
         :command-palette/prompt "Type a command"
         :select/default-prompt "Select one"
+        :select/default-select-multiple "Select one or multiple"
         :select.graph/prompt "Select a graph"
         :select.graph/empty-placeholder-description "No matched graphs. Do you want to add another one?"
         :select.graph/add-graph "Yes, add another graph"

+ 9 - 3
src/main/frontend/extensions/video/youtube.cljs

@@ -122,10 +122,10 @@ Remember: You can paste a raw YouTube url as embedded video on mobile."
   (let [reg #"^(?:(\d+):)?([0-5]\d):([0-5]\d)$"
         reg-number #"^\d+$"
         timestamp (str timestamp)
-        total-seconds (-> (re-matches reg-number timestamp)
-                          util/safe-parse-int)
+        total-seconds (some-> (re-matches reg-number timestamp)
+                              util/safe-parse-int)
         [_ hours minutes seconds] (re-matches reg timestamp)
-        [hours minutes seconds] (map util/safe-parse-int [hours minutes seconds])]
+        [hours minutes seconds] (map util/safe-parse-int (remove nil? [hours minutes seconds]))]
     (cond
       total-seconds
       total-seconds
@@ -133,6 +133,12 @@ Remember: You can paste a raw YouTube url as embedded video on mobile."
       (and minutes seconds)
       (+ (* 3600 hours) (* 60 minutes) seconds)
 
+      minutes
+      (+ (* 3600 hours) (* 60 minutes))
+
+      hours
+      (* 3600 hours)
+
       :else
       nil)))
 

+ 7 - 2
src/main/frontend/handler/command_palette.cljs

@@ -80,13 +80,18 @@
   [{:keys [id] :as command}]
   (if (:command/shortcut command)
     (log/error :shortcut/missing (str "Shortcut is missing for " (:id command)))
-    (do
+    (try
       (spec/validate :command/command command)
       (let [cmds (get-commands)]
         (if (some (fn [existing-cmd] (= (:id existing-cmd) id)) cmds)
           (log/error :command/register {:msg "Failed to register command. Command with same id already exist"
                                         :id  id})
-          (state/set-state! :command-palette/commands (conj cmds command)))))))
+          (state/set-state! :command-palette/commands (conj cmds command))))
+      ;; Catch unexpected errors so that subsequent register calls pass
+      (catch :default e
+        (log/error :command/register {:msg "Unexpectedly failed to register command"
+                                      :id id
+                                      :error (str e)})))))
 
 (defn unregister
   [id]

+ 16 - 0
src/main/frontend/handler/editor.cljs

@@ -9,6 +9,7 @@
             [frontend.db :as db]
             [frontend.db.model :as db-model]
             [frontend.db.utils :as db-utils]
+            [frontend.db.query-dsl :as query-dsl]
             [frontend.diff :as diff]
             [frontend.format.block :as block]
             [frontend.format.mldoc :as mldoc]
@@ -3215,6 +3216,20 @@
            (mldoc/block-with-title? first-elem-type))
          true)))
 
+(defn- valid-dsl-query-block?
+  "Whether block has a valid dsl query."
+  [block]
+  (->> (:block/macros (db/entity (:db/id block)))
+       (some (fn [macro]
+               (when-let [query-body (and
+                                      (= "query" (get-in macro [:block/properties :logseq.macro-name]))
+                                      (first (:logseq.macro-arguments (:block/properties macro))))]
+                 (seq (:query
+                       (try
+                         (query-dsl/parse-query query-body)
+                         (catch :default _e
+                           nil)))))))))
+
 (defn collapsable?
   ([block-id]
    (collapsable? block-id {}))
@@ -3223,6 +3238,7 @@
    (when block-id
      (if-let [block (db-model/query-block-by-uuid block-id)]
        (or (db-model/has-children? block-id)
+           (valid-dsl-query-block? block)
            (and
             (:outliner/block-title-collapse-enabled? (state/get-config))
             (block-with-title? (:block/format block)

+ 197 - 0
src/main/frontend/handler/query/builder.cljs

@@ -0,0 +1,197 @@
+(ns frontend.handler.query.builder
+  "DSL query builder handler"
+  (:require [clojure.walk :as walk]
+            [logseq.graph-parser.util.page-ref :as page-ref]
+            [lambdaisland.glogi :as log]
+            [frontend.db.query-dsl :as query-dsl]))
+
+;; TODO: make it extensible for Datalog/SPARQL etc.
+
+(def operators [:and :or :not])
+(def operators-set (set operators))
+(def page-filters ["all page tags"
+                   "namespace"
+                   "tags"
+                   "property"
+                   "sample"])
+(def block-filters ["page reference"
+                    "property"
+                    "task"
+                    "priority"
+                    "page"
+                    "full text search"
+                    "between"
+                    "sample"])
+
+(defn- vec-dissoc-item
+  [vec idx]
+  (into (subvec vec 0 idx) (subvec vec (inc idx))))
+
+(defn- vec-assoc-item
+  [vec idx item]
+  (into (conj (subvec vec 0 idx) item)
+        (subvec vec idx)))
+
+(defn- vec-replace-item
+  [v idx item]
+  (into (if (and (coll? item)
+                 (not (operators-set (first item))))
+          (vec (concat (subvec v 0 idx) item))
+          (conj (subvec v 0 idx) item))
+        (subvec v (inc idx))))
+
+(defn add-element
+  [q loc x]
+  {:pre [(vector? loc) (some? x)]}
+  (cond
+    (and (seq loc) (= 1 (count loc)))
+    (vec-assoc-item q (first loc) x)
+
+    (seq loc)
+    (update-in q (vec (butlast loc))
+               (fn [v]
+                 (vec-assoc-item v (last loc) x)))
+
+    (seq q)
+    (conj q x)
+
+    :else
+    [x]))
+
+(defn append-element
+  [q loc x]
+  {:pre [(vector? loc) (some? x)]}
+  (let [idx (count (get-in q (vec (butlast loc))))
+        loc' (vec-replace-item loc (dec (count loc)) idx)]
+    (add-element q loc' x)))
+
+(defn remove-element
+  [q loc]
+  (if (seq loc)
+    (let [idx (last loc)
+          ks (vec (butlast loc))
+          f #(vec-dissoc-item % idx)]
+      (if (seq ks)
+        (let [result (update-in q ks f)]
+          (if (seq (get-in result ks))
+            result
+            ;; remove the wrapped empty vector
+            (remove-element result ks)))
+        (f q)))
+    ;; default to AND operator
+    [:and]))
+
+(defn replace-element
+  [q loc x]
+  {:pre [(vector? loc) (seq loc) (some? x)]}
+  (if (= 1 (count loc))
+    (vec-replace-item q (first loc) x)
+    (update-in q (vec (butlast loc))
+               (fn [v]
+                 (vec-replace-item v (last loc) x)))))
+
+(defn- fallback-to-default [result default-value failed-data]
+  (if (empty? result)
+    (do
+      (log/error :query-builder/wrap-unwrap-operator-failed failed-data)
+      default-value)
+    result))
+
+(defn wrap-operator
+  [q loc operator]
+  {:pre [(seq q) (operators-set operator)]}
+  (let [result (if (or (= loc [0]) (empty? loc))
+                 [operator q]
+                 (when-let [x (get-in q loc)]
+                   (let [x' [operator x]]
+                     (replace-element q loc x'))))]
+    (fallback-to-default result q {:op "wrap-operator"
+                                   :q q
+                                   :loc loc
+                                   :operator operator})))
+
+(defn unwrap-operator
+  [q loc]
+  {:pre [(seq q) (seq loc)]}
+  (let [result (if (and (= loc [0]) (operators-set (first q)))
+                 (second q)
+                 (when-let [x (get-in q loc)]
+                   (when (and (operators-set (first x))
+                              (seq (rest x)))
+                     (let [x' (rest x)]
+                       (replace-element q loc x')))))]
+    (fallback-to-default result q {:op "unwrap-operator"
+                                   :q q
+                                   :loc loc})))
+
+(defn ->page-ref
+  [x]
+  (if (string? x)
+    (symbol (page-ref/->page-ref x))
+    (->page-ref (second x))))
+
+(defn- ->dsl*
+  [f]
+  (cond
+    (and (vector? f) (= :priority (keyword (first f))))
+    (vec (cons (symbol :priority) (map symbol (rest f))))
+
+    (and (vector? f) (= :task (keyword (first f))))
+    (vec (cons (symbol :task) (map symbol (rest f))))
+
+    (and (vector? f) (= :page-ref (keyword (first f))))
+    (->page-ref (second f))
+
+    (and (vector? f) (= :page-tags (keyword (first f))))
+    [(symbol :page-tags) (->page-ref (second f))]
+
+    (and (vector? f) (= :between (keyword (first f))))
+    (into [(symbol :between)] (map ->page-ref (rest f)))
+
+    ;; property key value
+    (and (vector? f) (= 3 (count f)) (contains? #{:page-property :property} (keyword (first f))))
+    (let [l (if (page-ref/page-ref? (str (last f)))
+              (symbol (last f))
+              (last f))]
+      (into [(symbol (first f))] [(second f) l]))
+
+    (and (vector? f) (contains? #{:page :namespace :tags} (keyword (first f))))
+    (into [(symbol (first f))] (map ->page-ref (rest f)))
+
+    :else f))
+
+(defn ->dsl
+  [col]
+  (->
+   (walk/prewalk
+    (fn [f]
+      (let [f' (->dsl* f)]
+        (cond
+          (and (vector? f') (keyword (first f')))
+          (cons (symbol (first f')) (rest f'))
+
+          :else f')))
+    col)
+   (query-dsl/simplify-query)))
+
+(defn from-dsl
+  [dsl-form]
+  (walk/prewalk
+   (fn [f]
+     (cond
+       (and (vector? f) (vector? (first f)))
+       [:page-ref (page-ref/get-page-name (str f))]
+
+       (and (string? f) (page-ref/get-page-name f))
+       [:page-ref (page-ref/get-page-name f)]
+
+       (and (list? f)
+            (symbol? (first f))
+            (operators-set (keyword (first f)))) ; operator
+       (into [(keyword (first f))] (rest f))
+
+       (list? f)
+       (vec f)
+
+       :else f))
+   dsl-form))

+ 1 - 3
src/main/frontend/handler/ui.cljs

@@ -96,9 +96,7 @@
      (if clear-all-query-state?
        (db/clear-query-state!)
        (db/clear-query-state-without-refs-and-embeds!))
-     (rum/request-render component)
-     (doseq [component (state/get-custom-query-components)]
-       (rum/request-render component)))))
+     (rum/request-render component))))
 
 (defn highlight-element!
   [fragment]

+ 1 - 1
src/main/frontend/modules/shortcut/core.cljs

@@ -166,7 +166,7 @@
     (events/listen handler EventType/SHORTCUT_TRIGGERED dispatch-fn)))
 
 (defn disable-all-shortcuts []
-  {:did-mount
+  {:will-mount
    (fn [state]
      (unlisten-all)
      state)

+ 8 - 4
src/main/frontend/search.cljs

@@ -181,16 +181,20 @@
          (let [result (fuzzy-search (keys templates) q :limit limit)]
            (vec (select-keys templates result))))))))
 
+(defn get-all-properties
+  []
+  (->> (db-model/get-all-properties)
+       (remove (property/hidden-properties))
+       ;; Complete full keyword except the ':'
+       (map #(subs (str %) 1))))
+
 (defn property-search
   ([q]
    (property-search q 100))
   ([q limit]
    (when q
      (let [q (clean-str q)
-           properties (->> (db-model/get-all-properties)
-                           (remove (property/hidden-properties))
-                           ;; Complete full keyword except the ':'
-                           (map #(subs (str %) 1)))]
+           properties (get-all-properties)]
        (when (seq properties)
          (if (string/blank? q)
            properties

+ 0 - 17
src/main/frontend/state.cljs

@@ -92,7 +92,6 @@
      :ui/sidebar-collapsed-blocks           {}
      :ui/root-component                     nil
      :ui/file-component                     nil
-     :ui/custom-query-components            {}
      :ui/show-recent?                       false
      :ui/developer-mode?                    (or (= (storage/get "developer-mode") "true")
                                                 false)
@@ -1254,22 +1253,6 @@ Similar to re-frame subscriptions"
   (when value
     (set-state! :journals-length value)))
 
-(defn add-custom-query-component!
-  [query-string component]
-  (update-state! :ui/custom-query-components
-                 (fn [m]
-                   (assoc m query-string component))))
-
-(defn remove-custom-query-component!
-  [query-string]
-  (update-state! :ui/custom-query-components
-                 (fn [m]
-                   (dissoc m query-string))))
-
-(defn get-custom-query-components
-  []
-  (vals (get @state :ui/custom-query-components)))
-
 (defn save-scroll-position!
   ([value]
    (save-scroll-position! value js/window.location.hash))

+ 7 - 6
src/main/frontend/ui.cljs

@@ -109,11 +109,12 @@
      :will-unmount (fn [state]
                      (state/update-state! :modal/dropdowns #(dissoc % (::k state)))
                      state)}
-  [dropdown-state _close-fn content class]
+  [dropdown-state _close-fn content class style-opts]
   (let [class (or class
                   (util/hiccup->class "origin-top-right.absolute.right-0.mt-2"))]
     [:div.dropdown-wrapper
-     {:class (str class " "
+     {:style style-opts
+      :class (str class " "
                   (case dropdown-state
                     "entering" "transition ease-out duration-100 transform opacity-0 scale-95"
                     "entered" "transition ease-out duration-100 transform opacity-100 scale-100"
@@ -129,13 +130,13 @@
   (let [{:keys [open?]} state
         modal-content (modal-content-fn state)
         close-fn (:close-fn state)]
-    [:div.relative.ui__dropdown-trigger {:style {:z-index z-index} :class trigger-class}
+    [:div.relative.ui__dropdown-trigger {:class trigger-class}
      (content-fn state)
      (css-transition
       {:in @open? :timeout 0}
       (fn [dropdown-state]
         (when @open?
-          (dropdown-content-wrapper dropdown-state close-fn modal-content modal-class))))]))
+          (dropdown-content-wrapper dropdown-state close-fn modal-content modal-class {:z-index z-index}))))]))
 
 ;; `sequence` can be a list of symbols, a list of strings, or a string
 (defn render-keyboard-shortcut [sequence]
@@ -817,11 +818,11 @@
   ([options on-change]
    (select options on-change nil))
   ([options on-change class]
-   [:select.pl-6.mt-1.block.text-base.leading-6.border-gray-300.focus:outline-none.focus:shadow-outline-blue.focus:border-blue-300.sm:text-sm.sm:leading-5.ml-1.sm:ml-4.w-12.sm:w-20
+   [:select.pl-6.block.text-base.leading-6.border-gray-300.focus:outline-none.focus:shadow-outline-blue.focus:border-blue-300.sm:text-sm.sm:leading-5
     {:class     (or class "form-select")
      :on-change (fn [e]
                   (let [value (util/evalue e)]
-                    (on-change value)))}
+                    (on-change e value)))}
     (for [{:keys [label value selected disabled]
            :or {selected false disabled false}} options]
       [:option (cond->

+ 4 - 0
src/main/frontend/ui.css

@@ -404,3 +404,7 @@ html.is-mobile {
     content: " ";
   }
 }
+
+.ui__radio-list {
+  @apply grid grid-flow-col gap-2;
+}

+ 7 - 0
src/main/frontend/util.cljc

@@ -1521,3 +1521,10 @@ Arg *stop: atom, reset to true to stop the loop"
   "Vector version of remove. non-lazy"
   [pred coll]
   `(vec (remove ~pred ~coll)))
+
+#?(:cljs
+   (defn safe-with-meta
+     [o meta]
+     (if (satisfies? IMeta o)
+       (with-meta o meta)
+       o)))

+ 44 - 0
src/test/frontend/db/query_dsl_test.cljs

@@ -45,6 +45,30 @@
 ;; Tests
 ;; =====
 
+(deftest pre-transform-test
+  (testing "page references should be quoted and tags should be handled"
+    (are [x y] (= (query-dsl/pre-transform x) y)
+     "#foo"
+     "#tag foo"
+
+     "(and #foo)"
+     "(and #tag foo)"
+
+     "[[test #foo]]"
+     "\"[[test #foo]]\""
+
+     "(and [[test #foo]] (or #foo))"
+     "(and \"[[test #foo]]\" (or #tag foo))"
+
+     "\"for #clojure\""
+     "\"for #clojure\""
+
+     "(and \"for #clojure\")"
+     "(and \"for #clojure\")"
+
+     "(and \"for #clojure\" #foo)"
+     "(and \"for #clojure\" #tag foo)")))
+
 (defn- block-property-queries-test
   []
   (load-test-files [{:file/path "journals/2022_02_28.md"
@@ -529,6 +553,26 @@ created-at:: 1608968448116
            (->> (dsl-query "(and (page-property rating) (sort-by rating))")
                 (map #(get-in % [:block/properties :rating])))))))
 
+(deftest simplify-query
+  (are [x y] (= (query-dsl/simplify-query x) y)
+    '(and [[foo]])
+    '[[foo]]
+
+    '(and (and [[foo]]))
+    '[[foo]]
+
+    '(and (or [[foo]]))
+    '[[foo]]
+
+    '(and (not [[foo]]))
+    '(not [[foo]])
+
+    '(and (or (and [[foo]])))
+    '[[foo]]
+
+    '(not (or [[foo]]))
+    '(not [[foo]])))
+
 (comment
  (require '[clojure.pprint :as pprint])
  (test-helper/start-test-db!)

+ 38 - 0
src/test/frontend/handler/query/builder_test.cljs

@@ -0,0 +1,38 @@
+(ns frontend.handler.query.builder-test
+  (:require [frontend.handler.query.builder :as query-builder]
+            [clojure.test :refer [deftest is]]))
+
+(deftest builder
+  (let [q []]
+    (is (= (query-builder/wrap-operator [:page-ref "foo"] [0] :and)
+           [:and [:page-ref "foo"]]))
+    (is (= (query-builder/unwrap-operator [:and [:page-ref "foo"]] [0])
+           [:page-ref "foo"]))
+    (is (= (-> (query-builder/add-element q [0] :and)
+               (query-builder/add-element [1] [:page-ref "foo"])
+               (query-builder/add-element [2] [:page-ref "bar"])
+               (query-builder/wrap-operator [1] :or)
+               (query-builder/unwrap-operator [1]))
+           [:and [:page-ref "foo"] [:page-ref "bar"]]))
+    (is (= (-> (query-builder/add-element q [0] :or)
+               (query-builder/add-element [1] [:page-ref "foo"])
+               (query-builder/add-element [2] [:page-ref "bar"])
+               (query-builder/wrap-operator [2] :and)
+               (query-builder/unwrap-operator [2]))
+           [:or [:page-ref "foo"] [:page-ref "bar"]]))))
+
+(deftest to-dsl
+  (is (= (str (query-builder/->dsl [:and [:page-ref "foo"] [:page-ref "bar"]]))
+         (str '(and [[foo]] [[bar]]))))
+  (is (= (str (query-builder/->dsl [:and [:page-ref "foo"] [:or [:page-ref "bar"] [:property :key :value]]]))
+         (str '(and [[foo]] (or [[bar]] (property :key :value))))))
+  (is (= (str (query-builder/->dsl [:and [:priority "A"] [:task "NOW"]]))
+         (str '(and (priority A) (task NOW))))))
+
+(deftest from-dsl
+  (is (= (query-builder/from-dsl '(and [[foo]] [[bar]]))
+         [:and [:page-ref "foo"] [:page-ref "bar"]]))
+  (is (= (query-builder/from-dsl '(and [[foo]] (or [[bar]] (:property :key :value))))
+         [:and [:page-ref "foo"] [:or [:page-ref "bar"] [:property :key :value]]]))
+  (is (= (query-builder/from-dsl '(and (priority A) (task NOW)))
+         [:and ['priority 'A] ['task 'NOW]])))