(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.string :as string] [frontend.commands :as commands] [frontend.components.cmdk.core :as cmdk] [frontend.components.block :as block] [frontend.components.settings :as settings] [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.property.dialog :as property-dialog] [frontend.config :as config] [frontend.context.i18n :refer [t]] [logseq.shui.ui :as shui] [frontend.db :as db] [frontend.db.conn :as conn] [frontend.db.model :as db-model] [frontend.db.persist :as db-persist] [frontend.db.transact :as db-transact] [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.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.file-based.nfs :as nfs-handler] [frontend.handler.code :as code-handler] [frontend.handler.db-based.rtc :as rtc-handler] [frontend.handler.graph :as graph-handler] [frontend.handler.db-based.property :as db-property-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.common.config :as common-config] [logseq.common.util :as common-util] [promesa.core :as p] [lambdaisland.glogi :as log] [rum.core :as rum] [frontend.rum :as r] [frontend.persist-db.browser :as db-browser] [frontend.modules.outliner.pipeline :as pipeline] [frontend.date :as date] [logseq.db :as ldb] [frontend.persist-db :as persist-db] [frontend.handler.export :as export] [frontend.extensions.fsrs :as fsrs])) ;; 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? (db-transact/request-finished?)] (if (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? @db-transact/*unfinished-request-ids))) (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))))))))) (shui/dialog-open! (file-sync/pick-dest-to-sync-panel graph)))) (defmethod handle :graph/pick-page-histories [[_ graph-uuid page-name]] (shui/dialog-open! (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 [{:keys [close]}] [: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)))]))) (defmethod handle :modal/nfs-ask-permission [] (when-let [repo (get-local-repo)] (some-> (ask-permission repo) (shui/dialog-open! {:align :top})))) (defmethod handle :modal/show-cards [[_ cards-id]] (let [db-based? (config/db-based-graph? (state/get-current-repo))] (shui/dialog-open! (if db-based? (fn [] (fsrs/cards-view cards-id)) srs/global-cards) {:id :srs :label "flashcards__cp"}))) (defmethod handle :modal/show-instruction [_] (shui/dialog-open! capacitor-fs/instruction {:id :instruction :label "instruction__cp"})) (defmethod handle :modal/show-themes-modal [[_ classic?]] (if classic? (plugin/open-select-theme!) (route-handler/go-to-search! :themes))) (defmethod handle :modal/toggle-appearance-modal [_] (let [label "customize-appearance"] (if (shui/dialog-get label) (shui/dialog-close! label) (shui/dialog-open! #(settings/modal-appearance-inner) {:id label :overlay-props {:label label} :label label})))) (defmethod handle :modal/set-git-username-and-email [[_ _content]] (shui/dialog-open! 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 [_] (shui/dialog-open! [:div {:style {:max-width 700}} [:p (t :sync-from-local-changes-detected)] [:div.flex.justify-end (ui/button (t :yes) :autoFocus "on" :class "ui__modal-enter" :on-click (fn [] (shui/dialog-close!) (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/> 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-current-block [_] (editor-handler/save-current-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 [[_ {:keys [block target] :as opts}]] (p/do! (editor-handler/save-current-block!) (let [editing-block (state/get-edit-block) pos (state/get-edit-pos) edit-block-or-selected (if editing-block [editing-block] (seq (keep #(db/entity [:block/uuid %]) (state/get-selection-block-ids)))) current-block (when-let [s (state/get-current-page)] (when (util/uuid-string? s) (db/entity [:block/uuid (uuid s)]))) blocks (or (when block [block]) edit-block-or-selected (when current-block [current-block])) opts' (cond-> opts editing-block (assoc :original-block editing-block :edit-original-block (fn [{:keys [editing-default-property?]}] (when editing-block (let [content (:block/title (db/entity (:db/id editing-block))) esc? (= "Escape" (state/get-ui-last-key-code)) [content' pos] (cond esc? [nil pos] (and (>= (count content) pos) (>= pos 2) (= (util/nth-safe content (dec pos)) (util/nth-safe content (- pos 2)) ";")) [(str (common-util/safe-subs content 0 (- pos 2)) (common-util/safe-subs content pos)) (- pos 2)] :else [nil pos])] (when content' (if editing-default-property? (editor-handler/save-block! (state/get-current-repo) (:block/uuid editing-block) content') (editor-handler/edit-block! editing-block (or pos :max) (cond-> {} content' (assoc :custom-content content'))))))))))] (when (seq blocks) (let [target' (or target (some-> (state/get-edit-input-id) (gdom/getElement)) (first (state/get-selection-blocks)))] (if target' (shui/popup-show! target' #(property-dialog/dialog blocks opts') {:align "start" :auto-focus? true}) (shui/dialog-open! #(property-dialog/dialog blocks opts') {:id :property-dialog :align "start"}))))))) (defmethod handle :editor/upsert-type-block [[_ {:keys [block type]}]] (p/do! (editor-handler/save-current-block!) (p/delay 16) (let [block (db/entity (:db/id block)) block-type (:logseq.property.node/display-type block) block-title (:block/title block) turn-type! #(db-property-handler/set-block-property! (:block/uuid %) :logseq.property.node/display-type (keyword type))] (if (or (not (nil? block-type)) (not (string/blank? block-title))) ;; insert block (let [[p _ block'] (editor-handler/insert-new-block-aux! {} block "")] (some-> p (p/then #(turn-type! block')) (p/then #(state/set-pending-type-block! block')))) (-> (turn-type! block) (p/then #(when (string/blank? block-title) (state/set-pending-type-block! block)))))))) (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: "] [:div.border.rounded.bg-gray-01.overflow-hidden.w-60 (repo/repos-dropdown {:on-click (fn [e] (util/stop e) (state/set-state! :error/multiple-tabs-access-opfs? false) (shui/dialog-close!))})]]])) (defmethod handle :show/multiple-tabs-error-dialog [_] (state/set-state! :error/multiple-tabs-access-opfs? true) (shui/dialog-open! multi-tabs-dialog)) (defmethod handle :rtc/sync-state [[_ state]] (state/update-state! :rtc/state (fn [old] (merge old state)))) (defmethod handle :rtc/log [[_ data]] (state/set-state! :rtc/log data)) (defmethod handle :rtc/download-remote-graph [[_ graph-name graph-uuid]] (-> (p/do! (rtc-handler/ UI (defmethod handle :db/sync-changes [[_ data]] (let [retract-datoms (filter (fn [d] (and (= :block/uuid (:a d)) (false? (:added d)))) (:tx-data data)) retracted-tx-data (map (fn [d] [:db/retractEntity (:e d)]) retract-datoms) tx-data (concat (:tx-data data) retracted-tx-data)] (pipeline/invoke-hooks (assoc data :tx-data tx-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))