(ns frontend.handler.events "System-component-like ns that defines named events and listens on a core.async channel to handle them. Any part of the system can dispatch one of these events using state/pub-event!" (:refer-clojure :exclude [run!]) (:require ["@capacitor/filesystem" :refer [Directory Filesystem]] ["@sentry/react" :as Sentry] [cljs-bean.core :as bean] [clojure.core.async :as async] [clojure.core.async.interop :refer [p->c]] [clojure.set :as set] [clojure.string :as string] [frontend.commands :as commands] [frontend.components.class :as class-component] [frontend.components.cmdk :as cmdk] [frontend.components.diff :as diff] [frontend.components.encryption :as encryption] [frontend.components.file-sync :as file-sync] [frontend.components.git :as git-component] [frontend.components.plugins :as plugin] [frontend.components.shell :as shell] [frontend.components.whiteboard :as whiteboard] [frontend.components.user.login :as login] [frontend.components.repo :as repo] [frontend.components.db-based.page :as db-page] [frontend.config :as config] [frontend.context.i18n :refer [t]] [frontend.db :as db] [frontend.db.conn :as conn] [frontend.db.model :as db-model] [frontend.db.persist :as db-persist] [frontend.extensions.srs :as srs] [frontend.fs :as fs] [frontend.fs.capacitor-fs :as capacitor-fs] [frontend.fs.nfs :as nfs] [frontend.fs.sync :as sync] [frontend.fs.watcher-handler :as fs-watcher] [frontend.handler.common :as common-handler] [frontend.handler.editor :as editor-handler] [frontend.handler.file :as file-handler] [frontend.handler.file-sync :as file-sync-handler] [frontend.handler.notification :as notification] [frontend.handler.page :as page-handler] [frontend.handler.common.page :as page-common-handler] [frontend.handler.plugin :as plugin-handler] [frontend.handler.repo :as repo-handler] [frontend.handler.repo-config :as repo-config-handler] [frontend.handler.route :as route-handler] [frontend.handler.search :as search-handler] [frontend.handler.shell :as shell-handler] [frontend.handler.ui :as ui-handler] [frontend.handler.user :as user-handler] [frontend.handler.property.util :as pu] [frontend.handler.db-based.property.util :as db-pu] [frontend.handler.file-based.property.util :as property-util] [frontend.handler.property :as property-handler] [frontend.handler.web.nfs :as nfs-handler] [frontend.handler.code :as code-handler] [frontend.mobile.core :as mobile] [frontend.mobile.graph-picker :as graph-picker] [frontend.mobile.util :as mobile-util] [frontend.modules.instrumentation.posthog :as posthog] [frontend.modules.instrumentation.sentry :as sentry-event] [frontend.modules.shortcut.core :as st] [frontend.quick-capture :as quick-capture] [frontend.search :as search] [frontend.state :as state] [frontend.ui :as ui] [frontend.util :as util] [frontend.util.persist-var :as persist-var] [goog.dom :as gdom] [logseq.db.frontend.schema :as db-schema] [logseq.common.config :as common-config] [promesa.core :as p] [lambdaisland.glogi :as log] [rum.core :as rum] [frontend.rum :as r] [frontend.persist-db.browser :as db-browser] [frontend.db.rtc.debug-ui :as rtc-debug-ui] [frontend.modules.outliner.pipeline :as pipeline] [electron.ipc :as ipc] [frontend.date :as date] [logseq.db :as ldb])) ;; TODO: should we move all events here? (defmulti handle first) (defn file-sync-restart! [] (async/go (async/c (persist-var/load-vars))) (async/ (sync/c (persist-var/load-vars))) (async/atom {}) (let [^js sqlite @db-browser/*worker] (p/let [writes-finished? (when sqlite (.file-writes-finished? sqlite (state/get-current-repo))) request-finished? (ldb/request-finished?)] (if (or (not request-finished?) (not writes-finished?)) ; TODO: test (:sync-graph/init? @state/state) (do (log/info :graph/switch (cond-> {:request-finished? request-finished? :file-writes-finished? writes-finished?} (false? request-finished?) (assoc :unfinished-requests? @ldb/*request-id->response))) (notification/show! "Please wait seconds until all changes are saved for the current graph." :warning)) (graph-switch-on-persisted graph opts))))) (defmethod handle :graph/pull-down-remote-graph [[_ graph dir-name]] (if (mobile-util/native-ios?) (when-let [graph-name (or dir-name (:GraphName graph))] (let [graph-name (util/safe-sanitize-file-name graph-name)] (if (string/blank? graph-name) (notification/show! "Illegal graph folder name.") ;; Create graph directory under Logseq document folder (local) (when-let [root (state/get-local-container-root-url)] (let [graph-path (graph-picker/validate-graph-dirname root graph-name)] (-> (p/let [exists? (fs/dir-exists? graph-path)] (let [overwrite? (if exists? (js/confirm (str "There's already a directory with the name \"" graph-name "\", do you want to overwrite it? Make sure to backup it first if you're not sure about it.")) true)] (if overwrite? (p/let [_ (fs/mkdir-if-not-exists graph-path)] (nfs-handler/ls-dir-files-with-path! graph-path {:ok-handler (fn [] (file-sync-handler/init-remote-graph graph-path graph) (js/setTimeout (fn [] (repo-handler/refresh-repos!)) 200))})) (let [graph-name (-> (js/prompt "Please specify a new directory name to download the graph:") str string/trim)] (when-not (string/blank? graph-name) (state/pub-event! [:graph/pull-down-remote-graph graph graph-name])))))) (p/catch (fn [^js e] (notification/show! (str e) :error) (js/console.error e))))))))) (state/set-modal! (file-sync/pick-dest-to-sync-panel graph) {:center? true}))) (defmethod handle :graph/pick-page-histories [[_ graph-uuid page-name]] (state/set-modal! (file-sync/pick-page-histories-panel graph-uuid page-name) {:id :page-histories :label "modal-page-histories"})) (defmethod handle :graph/open-new-window [[_ev target-repo]] (p/let [current-repo (state/get-current-repo)] (ui-handler/open-new-window-or-tab! current-repo target-repo))) (defmethod handle :graph/migrated [[_ _repo]] (js/alert "Graph migrated.")) (defn get-local-repo [] (when-let [repo (state/get-current-repo)] (when (config/local-file-based-graph? repo) repo))) (defn ask-permission [repo] (when (and (not (util/electron?)) (not (mobile-util/native-platform?))) (fn [close-fn] [:div ;; TODO: fn translation with args [:p "Grant native filesystem permission for directory: " [:b (config/get-local-dir repo)]] (ui/button (t :settings-permission/start-granting) :class "ui__modal-enter" :on-click (fn [] (nfs/check-directory-permission! repo) (close-fn)))]))) (defmethod handle :modal/nfs-ask-permission [] (when-let [repo (get-local-repo)] (state/set-modal! (ask-permission repo)))) (defonce *query-properties (atom {})) (rum/defc query-properties-settings-inner < rum/reactive {:will-unmount (fn [state] (reset! *query-properties {}) state)} [block shown-properties all-properties] (let [query-properties (rum/react *query-properties)] [:div.p-4 [:div.font-bold (t :query/config-property-settings)] [:div.flex {:title "Refresh list of columns" :on-click (fn [] (reset! *query-properties {}) (property-handler/remove-block-property! (state/get-current-repo) (:block/uuid block) :query-properties))} (ui/icon "refresh")] (for [property all-properties] (let [property-value (get query-properties property) shown? (if (nil? property-value) (contains? shown-properties property) property-value)] [:div.flex.flex-row.m-2.justify-between.align-items [:div (if (uuid? property) (db-pu/get-property-name property) (name property))] [:div.mt-1 (ui/toggle shown? (fn [] (let [value (not shown?)] (swap! *query-properties assoc property value) (editor-handler/set-block-query-properties! (:block/uuid block) all-properties property value))) true)]]))])) (defn query-properties-settings [block shown-properties all-properties] (fn [_close-fn] (query-properties-settings-inner block shown-properties all-properties))) (defmethod handle :modal/set-query-properties [[_ block all-properties]] (let [properties (:block/properties block) query-properties (pu/lookup properties :query-properties) block-properties (if (config/db-based-graph? (state/get-current-repo)) query-properties (some-> query-properties (common-handler/safe-read-string "Parsing query properties failed"))) shown-properties (if (seq block-properties) (set block-properties) (set all-properties)) shown-properties (set/intersection (set all-properties) shown-properties)] (state/set-modal! (query-properties-settings block shown-properties all-properties) {:center? true}))) (defmethod handle :modal/show-cards [_] (state/set-modal! srs/global-cards {:id :srs :label "flashcards__cp"})) (defmethod handle :modal/show-instruction [_] (state/set-modal! capacitor-fs/instruction {:id :instruction :label "instruction__cp"})) (defmethod handle :modal/show-themes-modal [_] (plugin/open-select-theme!)) (rum/defc modal-output [content] content) (defmethod handle :modal/show [[_ content]] (state/set-modal! #(modal-output content))) (defmethod handle :modal/set-git-username-and-email [[_ _content]] (state/set-modal! git-component/set-git-username-and-email)) (defmethod handle :page/create [[_ page-name opts]] (if (= page-name (date/today)) (page-handler/create-today-journal!) (page-handler/js {:tags payload})))) (defmethod handle :exec-plugin-cmd [[_ {:keys [pid cmd action]}]] (commands/exec-plugin-simple-command! pid cmd action)) (defmethod handle :shortcut-handler-refreshed [[_]] (when-not @st/*pending-inited? (reset! st/*pending-inited? true) (st/consume-pending-shortcuts!))) (defmethod handle :mobile/keyboard-will-show [[_ keyboard-height]] (let [main-node (util/app-scroll-container-node)] (state/set-state! :mobile/show-tabbar? false) (state/set-state! :mobile/show-toolbar? true) (state/set-state! :mobile/show-action-bar? false) (when (= (state/sub :editor/record-status) "RECORDING") (state/set-state! :mobile/show-recording-bar? true)) (when (mobile-util/native-ios?) (reset! util/keyboard-height keyboard-height) (set! (.. main-node -style -marginBottom) (str keyboard-height "px")) (when-let [^js html (js/document.querySelector ":root")] (.setProperty (.-style html) "--ls-native-kb-height" (str keyboard-height "px")) (.add (.-classList html) "has-mobile-keyboard")) (when-let [left-sidebar-node (gdom/getElement "left-sidebar")] (set! (.. left-sidebar-node -style -bottom) (str keyboard-height "px"))) (when-let [right-sidebar-node (gdom/getElementByClass "sidebar-item-list")] (set! (.. right-sidebar-node -style -paddingBottom) (str (+ 150 keyboard-height) "px"))) (when-let [card-preview-el (js/document.querySelector ".cards-review")] (set! (.. card-preview-el -style -marginBottom) (str keyboard-height "px"))) (when-let [card-preview-el (js/document.querySelector ".encryption-password")] (set! (.. card-preview-el -style -marginBottom) (str keyboard-height "px"))) (js/setTimeout (fn [] (when-let [toolbar (.querySelector main-node "#mobile-editor-toolbar")] (set! (.. toolbar -style -bottom) (str keyboard-height "px")))) 100)))) (defmethod handle :mobile/keyboard-will-hide [[_]] (let [main-node (util/app-scroll-container-node)] (state/set-state! :mobile/show-toolbar? false) (state/set-state! :mobile/show-tabbar? true) (when (= (state/sub :editor/record-status) "RECORDING") (state/set-state! :mobile/show-recording-bar? false)) (when (mobile-util/native-ios?) (when-let [^js html (js/document.querySelector ":root")] (.removeProperty (.-style html) "--ls-native-kb-height") (.remove (.-classList html) "has-mobile-keyboard")) (when-let [card-preview-el (js/document.querySelector ".cards-review")] (set! (.. card-preview-el -style -marginBottom) "0px")) (when-let [card-preview-el (js/document.querySelector ".encryption-password")] (set! (.. card-preview-el -style -marginBottom) "0px")) (set! (.. main-node -style -marginBottom) "0px") (when-let [left-sidebar-node (gdom/getElement "left-sidebar")] (set! (.. left-sidebar-node -style -bottom) "0px")) (when-let [right-sidebar-node (gdom/getElementByClass "sidebar-item-list")] (set! (.. right-sidebar-node -style -paddingBottom) "150px")) (when-let [toolbar (.querySelector main-node "#mobile-editor-toolbar")] (set! (.. toolbar -style -bottom) 0))))) (defn- get-ios-app-id [repo-url] (when repo-url (let [app-id (-> (first (string/split repo-url "/Documents")) (string/split "/") last)] app-id))) (defmethod handle :validate-appId [[_ graph-switch-f graph]] (when-let [deprecated-repo (or graph (state/get-current-repo))] (if (mobile-util/in-iCloud-container-path? deprecated-repo) ;; Installation is not changed for iCloud (when graph-switch-f (graph-switch-f graph true) (state/pub-event! [:graph/ready (state/get-current-repo)])) ;; Installation is changed for App Documents directory (p/let [deprecated-app-id (get-ios-app-id deprecated-repo) current-document-url (.getUri Filesystem #js {:path "" :directory (.-Documents Directory)}) current-app-id (-> (js->clj current-document-url :keywordize-keys true) get-ios-app-id)] (if (= deprecated-app-id current-app-id) (when graph-switch-f (graph-switch-f graph true)) (do (notification/show! [:div "Migrating from previous App installation..."] :warning true) (prn ::migrate-app-id :from deprecated-app-id :to current-app-id) (file-sync-stop!) (.unwatch mobile-util/fs-watcher) (let [current-repo (string/replace deprecated-repo deprecated-app-id current-app-id) current-repo-dir (config/get-repo-dir current-repo)] (try ;; replace app-id part of repo url (reset! conn/conns (update-keys @conn/conns (fn [key] (if (string/includes? key deprecated-app-id) (string/replace key deprecated-app-id current-app-id) key)))) (db-persist/rename-graph! deprecated-repo current-repo) (search/remove-db! deprecated-repo) (state/add-repo! {:url current-repo :nfs? true}) (state/delete-repo! {:url deprecated-repo}) (catch :default e (js/console.error e))) (state/set-current-repo! current-repo) (repo-config-handler/restore-repo-config! current-repo) (when graph-switch-f (graph-switch-f current-repo true)) (.watch mobile-util/fs-watcher #js {:path current-repo-dir}) (file-sync-restart!)))) (state/pub-event! [:graph/ready (state/get-current-repo)]))))) (defmethod handle :plugin/consume-updates [[_ id prev-pending? updated?]] (let [downloading? (:plugin/updates-downloading? @state/state) auto-checking? (plugin-handler/get-auto-checking?)] (when-let [coming (and (not downloading?) (get-in @state/state [:plugin/updates-coming id]))] (let [error-code (:error-code coming) error-code (if (= error-code (str :no-new-version)) nil error-code) title (:title coming)] (when (and prev-pending? (not auto-checking?)) (if-not error-code (plugin/set-updates-sub-content! (str title "...") 0) (notification/show! (str "[Checked]<" title "> " error-code) :error))))) (if (and updated? downloading?) ;; try to start consume downloading item (if-let [next-coming (state/get-next-selected-coming-update)] (plugin-handler/check-or-update-marketplace-plugin! (assoc next-coming :only-check false :error-code nil) (fn [^js e] (js/console.error "[Download Err]" next-coming e))) (plugin-handler/close-updates-downloading)) ;; try to start consume pending item (if-let [next-pending (second (first (:plugin/updates-pending @state/state)))] (do (println "Updates: take next pending - " (:id next-pending)) (js/setTimeout #(plugin-handler/check-or-update-marketplace-plugin! (assoc next-pending :only-check true :auto-check auto-checking? :error-code nil) (fn [^js e] (notification/show! (.toString e) :error) (js/console.error "[Check Err]" next-pending e))) 500)) ;; try to open waiting updates list (do (when (and prev-pending? (not auto-checking?) (seq (state/all-available-coming-updates))) (plugin/open-waiting-updates-modal!)) (plugin-handler/set-auto-checking! false)))))) (defmethod handle :plugin/hook-db-tx [[_ {:keys [blocks tx-data] :as payload}]] (when-let [payload (and (seq blocks) (merge payload {:tx-data (map #(into [] %) tx-data)}))] (plugin-handler/hook-plugin-db :changed payload) (plugin-handler/hook-plugin-block-changes payload))) (defmethod handle :plugin/loader-perf-tip [[_ {:keys [^js o _s _e]}]] (when-let [opts (.-options o)] (notification/show! (plugin/perf-tip-content (.-id o) (.-name opts) (.-url opts)) :warning false (.-id o)))) (defmethod handle :mobile-file-watcher/changed [[_ ^js event]] (let [type (.-event event) payload (js->clj event :keywordize-keys true)] (fs-watcher/handle-changed! type payload) (when (file-sync-handler/enable-sync?) (sync/file-watch-handler type payload)))) (defmethod handle :rebuild-slash-commands-list [[_]] (page-handler/rebuild-slash-commands-list!)) (defmethod handle :shortcut/refresh [[_]] (st/refresh!)) (defn- refresh-cb [] (page-handler/create-today-journal!) (file-sync-restart!)) (defmethod handle :graph/ask-for-re-fresh [_] (handle [:modal/show [:div {:style {:max-width 700}} [:p (t :sync-from-local-changes-detected)] (ui/button (t :yes) :autoFocus "on" :class "ui__modal-enter" :on-click (fn [] (state/close-modal!) (nfs-handler/refresh! (state/get-current-repo) refresh-cb)))]])) (defmethod handle :sync/create-remote-graph [[_ current-repo]] (let [graph-name (js/decodeURI (util/node-path.basename current-repo))] (async/go (async/ Advanced > Filename format > click EDIT button)%s to avoid more potential bugs." (if (and util/mac? (not (mobile-util/native-ios?))) "" " in other devices"))]]]] :warning false)) (defmethod handle :graph/setup-a-repo [[_ opts]] (let [opts' (merge {:picked-root-fn #(state/close-modal!) :native-icloud? (not (string/blank? (state/get-icloud-container-root-url))) :logged? (user-handler/logged-in?)} opts)] (if (mobile-util/native-ios?) (state/set-modal! #(graph-picker/graph-picker-cp opts') {:label "graph-setup"}) (page-handler/ls-dir-files! st/refresh! opts')))) (defmethod handle :graph/new-db-graph [[_ _opts]] (state/set-modal! repo/new-db-graph {:id :new-db-graph :label "graph-setup"})) (defmethod handle :search/transact-data [[_ repo data]] (let [file-based? (config/local-file-based-graph? repo) data' (cond-> data file-based? ;; remove built-in properties from content (update :blocks-to-add (fn [blocks] (map #(update % :content (fn [content] (property-util/remove-built-in-properties (get % :format :markdown) content))) blocks))))] (search/transact-blocks! repo data'))) (defmethod handle :class/configure [[_ page]] (state/set-modal! #(vector :<> (class-component/configure page {}) (db-page/page-properties page {:configure? true})) {:id :page-configure :label "page-configure" :container-overflow-visible? true})) (defmethod handle :file/alter [[_ repo path content]] (p/let [_ (file-handler/alter-file repo path content {:from-disk? true})] (ui-handler/re-render-root!))) (defmethod handle :ui/re-render-root [[_]] (ui-handler/re-render-root!)) (rum/defcs file-id-conflict-item < (rum/local false ::resolved?) [state repo file data] (let [resolved? (::resolved? state) id (last (:assertion data))] [:li {:key file} [:div [:a {:on-click #(js/window.apis.openPath file)} file] (if @resolved? [:div.flex.flex-row.items-center (ui/icon "circle-check" {:style {:font-size 20}}) [:div.ml-1 "Resolved"]] [:div [:p (str "It seems that another whiteboard file already has the ID \"" id "\". You can fix it by changing the ID in this file with another UUID.")] [:p "Or, let me" (ui/button "Fix" :on-click (fn [] (let [dir (config/get-repo-dir repo)] (p/let [content (fs/read-file dir file)] (let [new-content (string/replace content (str id) (str (random-uuid)))] (p/let [_ (fs/write-file! repo dir file new-content {})] (reset! resolved? true)))))) :class "inline mx-1") "it."]])]])) (defmethod handle :file/parse-and-load-error [[_ repo parse-errors]] (state/pub-event! [:notification/show {:content [:div [:h2.title "Oops. These files failed to import to your graph:"] [:ol.my-2 (for [[file error] parse-errors] (let [data (ex-data error)] (cond (and (common-config/whiteboard? file) (= :transact/upsert (:error data)) (uuid? (last (:assertion data)))) (rum/with-key (file-id-conflict-item repo file data) file) :else (do (state/pub-event! [:capture-error {:error error :payload {:type :file/parse-and-load-error}}]) [:li.my-1 {:key file} [:a {:on-click #(js/window.apis.openPath file)} file] [:p (.-message error)]]))))] [:p "Don't forget to re-index your graph when all the conflicts are resolved."]] :status :error}])) (defmethod handle :run/cli-command [[_ command content]] (when (and command (not (string/blank? content))) (shell-handler/run-cli-command-wrapper! command content))) (defmethod handle :editor/quick-capture [[_ args]] (quick-capture/quick-capture args)) (defmethod handle :modal/keymap [[_]] (state/open-settings! :keymap)) (defmethod handle :editor/toggle-own-number-list [[_ blocks]] (let [batch? (sequential? blocks) blocks (cond->> blocks batch? (map #(cond-> % (or (uuid? %) (string? %)) (db-model/get-block-by-uuid))))] (if (and batch? (> (count blocks) 1)) (editor-handler/toggle-blocks-as-own-order-list! blocks) (when-let [block (cond-> blocks batch? (first))] (if (editor-handler/own-order-number-list? block) (editor-handler/remove-block-own-order-list-type! block) (editor-handler/make-block-as-own-order-list! block)))))) (defmethod handle :editor/remove-own-number-list [[_ block]] (when (some-> block (editor-handler/own-order-number-list?)) (editor-handler/remove-block-own-order-list-type! block))) (defmethod handle :editor/save-code-editor [_] (code-handler/save-code-editor!)) (defmethod handle :editor/toggle-children-number-list [[_ block]] (when-let [blocks (and block (db-model/get-block-immediate-children (state/get-current-repo) (:block/uuid block)))] (editor-handler/toggle-blocks-as-own-order-list! blocks))) (defmethod handle :editor/new-property [[_]] (p/do! (when-let [edit-block (state/get-edit-block)] (when-let [block-id (:block/uuid edit-block)] (let [block (db/entity [:block/uuid block-id]) collapsed? (or (get-in @state/state [:ui/collapsed-blocks (state/get-current-repo) block-id]) (:block/collapsed? block))] (when collapsed? (editor-handler/set-blocks-collapsed! [block-id] false))))) (editor-handler/save-current-block!) (property-handler/editing-new-property!))) (rum/defc multi-tabs-dialog [] (let [word (if (util/electron?) "window" "tab")] [:div.flex.p-4.flex-col.gap-4.h-64 [:span.warning.text-lg (util/format "Logseq doesn't support multiple %ss access to the same graph yet, please close this %s or switch to another graph." word word)] [:div.text-lg [:p "Switch to another repo: "] (repo/repos-dropdown {:on-click (fn [e] (util/stop e) (state/set-state! :error/multiple-tabs-access-opfs? false) (state/close-modal!))})]])) (defmethod handle :show/multiple-tabs-error-dialog [_] (state/set-state! :error/multiple-tabs-access-opfs? true) (state/set-modal! multi-tabs-dialog {:container-overflow-visible? true})) (defmethod handle :rtc/sync-state [[_ state]] (swap! rtc-debug-ui/debug-state (fn [old] (merge old state)))) ;; db-worker -> UI (defmethod handle :db/sync-changes [[_ data]] (let [repo (state/get-current-repo)] (pipeline/invoke-hooks data) (when (util/electron?) (ipc/ipc :db-transact repo (pr-str (:tx-data data)) (pr-str (:tx-meta data)))) nil)) (defn run! [] (let [chan (state/get-events-chan)] (async/go-loop [] (let [[payload d] (async/ (try (p/resolved (handle payload)) (catch :default error (p/rejected error))) (p/then (fn [result] (p/resolve! d result))) (p/catch (fn [error] (let [type :handle-system-events/failed] (state/pub-event! [:capture-error {:error error :payload {:type type :payload payload}}]) (p/reject! d error)))))) (recur)) chan)) (comment (let [{:keys [deprecated-app-id current-app-id]} {:deprecated-app-id "AFDADF9A-7466-4ED8-B74F-AAAA0D4565B9", :current-app-id "7563518E-0EFD-4AD2-8577-10CFFD6E4596"}] (def deprecated-app-id deprecated-app-id) (def current-app-id current-app-id)) (def deprecated-repo (state/get-current-repo)) (def new-repo (string/replace deprecated-repo deprecated-app-id current-app-id)) (update-file-path deprecated-repo new-repo deprecated-app-id current-app-id))