(ns frontend.handler.plugin (:require [promesa.core :as p] [rum.core :as rum] [frontend.util :as util] [frontend.fs :as fs] [frontend.format.mldoc :refer [->MldocMode] :as mldoc] [frontend.handler.notification :as notifications] [frontend.storage :as storage] [camel-snake-kebab.core :as csk] [frontend.state :as state] [medley.core :as md] [electron.ipc :as ipc] [reitit.frontend.easy :as rfe] [cljs-bean.core :as bean] [clojure.string :as string] [lambdaisland.glogi :as log] [frontend.components.svg :as svg] [frontend.format :as format])) (defonce lsp-enabled? (and (util/electron?) (= (storage/get "developer-mode") "true"))) (defn invoke-exported-api [type & args] (try (apply js-invoke (aget js/window.logseq "api") type args) (catch js/Error e (js/console.error e)))) ;; state handlers (defonce central-endpoint "https://raw.githubusercontent.com/logseq/marketplace/master/") (defonce plugins-url (str central-endpoint "plugins.json")) (defonce stats-url (str central-endpoint "stats.json")) (declare select-a-plugin-theme) (defn gh-repo-url [repo] (str "https://github.com/" repo)) (defn pkg-asset [id asset] (if (and asset (string/starts-with? asset "http")) asset (if-let [asset (and asset (string/replace asset #"^[./]+" ""))] (str central-endpoint "packages/" id "/" asset)))) (defn load-marketplace-plugins [refresh?] (if (or refresh? (nil? (:plugin/marketplace-pkgs @state/state))) (p/create (fn [resolve reject] (-> (util/fetch plugins-url (fn [res] (let [pkgs (:packages res)] (state/set-state! :plugin/marketplace-pkgs pkgs) (resolve pkgs))) reject) (p/catch reject)))) (p/resolved (:plugin/marketplace-pkgs @state/state)))) (defn load-marketplace-stats [refresh?] (if (or refresh? (nil? (:plugin/marketplace-stats @state/state))) (p/create (fn [resolve reject] (util/fetch stats-url (fn [res] (state/set-state! :plugin/marketplace-stats res) (resolve nil)) reject))) (p/resolved nil))) (defn installed? [id] (and (contains? (:plugin/installed-plugins @state/state) (keyword id)) (get-in @state/state [:plugin/installed-plugins (keyword id) :iir]))) (defn install-marketplace-plugin [{:keys [repo id] :as mft}] (when-not (and (:plugin/installing @state/state) (installed? id)) (p/create (fn [resolve] (state/set-state! :plugin/installing mft) (ipc/ipc "installMarketPlugin" mft) (resolve id))))) (defn update-marketplace-plugin [{:keys [id] :as pkg} error-handler] (when-not (and (:plugin/installing @state/state) (not (installed? id))) (p/catch (p/then (do (state/set-state! :plugin/installing pkg) (load-marketplace-plugins false)) (fn [mfts] (if-let [mft (some #(if (= (:id %) id) %) mfts)] (do (ipc/ipc "updateMarketPlugin" (merge (dissoc pkg :logger) mft))) (throw (js/Error. (str ":central-not-matched " id)))) true)) (fn [^js e] (error-handler "Update Error: remote error") (state/set-state! :plugin/installing nil) (js/console.error e))))) (defn get-plugin-inst [id] (try (js/LSPluginCore.ensurePlugin id) (catch js/Error e nil))) (defn setup-install-listener! [t] (let [channel (name :lsp-installed) listener (fn [^js _ ^js e] (js/console.debug :lsp-installed e) (when-let [{:keys [status payload]} (bean/->clj e)] (case (keyword status) :completed (let [{:keys [id dst name title version theme]} payload name (or title name "Untitled")] (if (installed? id) (when-let [^js pl (get-plugin-inst id)] ;; update (p/then (.reload pl) #(do ;;(if theme (select-a-plugin-theme id)) (notifications/show! (str (t :plugin/update) (t :plugins) ": " name " - " (.-version (.-options pl))) :success)))) (do ;; register new (p/then (js/LSPluginCore.register (bean/->js {:key id :url dst})) (fn [] (if theme (js/setTimeout #(select-a-plugin-theme id) 300)))) (notifications/show! (str (t :plugin/installed) (t :plugins) ": " name) :success)))) :error (let [[msg type] (case (keyword (string/replace payload #"^[\s\:]+" "")) :no-new-version [(str (t :plugin/up-to-date) " :)") :success] [payload :error])] (notifications/show! (str (if (= :error type) "[Install Error]" "") msg) type) (js/console.error payload)) :dunno)) ;; reset (state/set-state! :plugin/installing nil) true)] (js/window.apis.addListener channel listener) ;; clear (fn [] (js/window.apis.removeAllListeners channel)))) (defn register-plugin [pl] (swap! state/state update-in [:plugin/installed-plugins] assoc (keyword (:id pl)) pl)) (defn unregister-plugin [id] (js/LSPluginCore.unregister id)) (defn host-mounted! [] (and lsp-enabled? (js/LSPluginCore.hostMounted))) (defn register-plugin-slash-command [pid [cmd actions]] (when-let [pid (keyword pid)] (when (contains? (:plugin/installed-plugins @state/state) pid) (do (swap! state/state update-in [:plugin/installed-commands pid] (fnil merge {}) (hash-map cmd (mapv #(conj % {:pid pid}) actions))) true)))) (defn unregister-plugin-slash-command [pid] (swap! state/state md/dissoc-in [:plugin/installed-commands (keyword pid)])) (defn simple-cmd->palette-cmd [pid {:keys [key label type desc] :as cmd} action] (let [palette-cmd {:id (keyword (str "plugin." pid "/" key)) :desc (or desc label) :action (fn [] (state/pub-event! [:exec-plugin-cmd {:type type :key key :pid pid :cmd cmd :action action}]))}] palette-cmd)) (defn register-plugin-simple-command ;; action => [:action-key :event-key] [pid {:keys [key label type] :as cmd} action] (when-let [pid (keyword pid)] (when (contains? (:plugin/installed-plugins @state/state) pid) (do (swap! state/state update-in [:plugin/simple-commands pid] (fnil conj []) [type cmd action pid]) true)))) (defn unregister-plugin-simple-command [pid] (swap! state/state md/dissoc-in [:plugin/simple-commands (keyword pid)])) (defn register-plugin-ui-item [pid {:keys [key type template] :as opts}] (when-let [pid (keyword pid)] (when (contains? (:plugin/installed-plugins @state/state) pid) (do (swap! state/state update-in [:plugin/installed-ui-items pid] (fnil conj []) [type opts pid]) true)))) (defn unregister-plugin-ui-items [pid] (swap! state/state assoc-in [:plugin/installed-ui-items (keyword pid)] [])) (defn unregister-plugin-themes ([pid] (unregister-plugin-themes pid true)) ([pid effect] (js/LSPluginCore.unregisterTheme (name pid) effect))) (defn select-a-plugin-theme [pid] (when-let [themes (get (group-by :pid (:plugin/installed-themes @state/state)) pid)] (when-let [theme (first themes)] (let [theme-mode (:mode theme)] (and theme-mode (state/set-theme! (if (= theme-mode "light") "white" theme-mode))) (js/LSPluginCore.selectTheme (bean/->js theme)))))) (defn update-plugin-settings [id settings] (swap! state/state update-in [:plugin/installed-plugins id] assoc :settings settings)) (defn parse-user-md-content [content {:keys [url]}] (try (when-not (string/blank? content) (let [content (if-not (string/blank? url) (string/replace content #"!\[[^\]]*\]\((.*?)\s*(\"(?:.*[^\"])\")?\s*\)" (fn [[matched link]] (if (and link (not (string/starts-with? link "http"))) (string/replace matched link (util/node-path.join url link)) matched))) content)] (format/to-html content :markdown (mldoc/default-config :markdown)))) (catch js/Error e (log/error :parse-user-md-exception e) content))) (defn open-readme! [url item display] (let [repo (:repo item)] (if (nil? repo) ;; local (-> (p/let [content (invoke-exported-api "load_plugin_readme" url) content (parse-user-md-content content item)] (and (string/blank? (string/trim content)) (throw nil)) (state/set-state! :plugin/active-readme [content item]) (state/set-modal! (fn [_] (display)))) (p/catch #(do (js/console.warn %) (notifications/show! "No README content." :warn)))) ;; market (state/set-modal! (fn [_] (display repo nil)))))) (defn load-unpacked-plugin [] (when util/electron? (p/let [path (ipc/ipc "openDialogSync")] (when-not (:plugin/selected-unpacked-pkg @state/state) (state/set-state! :plugin/selected-unpacked-pkg path))))) (defn reset-unpacked-state [] (state/set-state! :plugin/selected-unpacked-pkg nil)) (defn hook-plugin [tag type payload plugin-id] (when lsp-enabled? (js-invoke js/LSPluginCore (str "hook" (string/capitalize (name tag))) (name type) (if (coll? payload) (bean/->js (into {} (for [[k v] payload] [(csk/->camelCase k) (if (uuid? v) (str v) v)]))) payload) (if (keyword? plugin-id) (name plugin-id) plugin-id)))) (defn hook-plugin-app ([type payload] (hook-plugin-app type payload nil)) ([type payload plugin-id] (hook-plugin :app type payload plugin-id))) (defn hook-plugin-editor ([type payload] (hook-plugin-editor type payload nil)) ([type payload plugin-id] (hook-plugin :editor type payload plugin-id))) (defn get-ls-dotdir-root [] (ipc/ipc "getLogseqDotDirRoot")) (defn show-themes-modal! [] (state/pub-event! [:modal/show-themes-modal])) (defn goto-plugins-dashboard! [] (rfe/push-state :plugins)) (defn- get-user-default-plugins [] (p/catch (p/let [files ^js (ipc/ipc "getUserDefaultPlugins") files (js->clj files)] (map #(hash-map :url %) files)) (fn [e] (js/console.error e)))) ;; components (rum/defc lsp-indicator < rum/reactive [] (let [text (state/sub :plugin/indicator-text)] (if-not (= text "END") [:div.flex.align-items.justify-center.h-screen.w-full [:span.flex.items-center.justify-center.w-60.flex-col [:small.scale-250.opacity-70.mb-10.animate-pulse (svg/logo false)] [:small.block.text-sm.relative.opacity-50 {:style {:right "-8px"}} text]]]))) (defn init-plugins! [callback] (let [el (js/document.createElement "div")] (.appendChild js/document.body el) (rum/mount (lsp-indicator) el)) (state/set-state! :plugin/indicator-text "LOADING") (p/then (p/let [root (get-ls-dotdir-root) _ (.setupPluginCore js/LSPlugin (bean/->js {:localUserConfigRoot root :dotConfigRoot root})) clear-commands! (fn [pid] ;; commands (unregister-plugin-slash-command pid) (invoke-exported-api "unregister_plugin_simple_command" pid) (unregister-plugin-ui-items pid)) _ (doto js/LSPluginCore (.on "registered" (fn [^js pl] (register-plugin (bean/->clj (.parse js/JSON (.stringify js/JSON pl)))))) (.on "reloaded" (fn [^js pl] (register-plugin (bean/->clj (.parse js/JSON (.stringify js/JSON pl)))))) (.on "unregistered" (fn [pid] (let [pid (keyword pid)] ;; effects (unregister-plugin-themes pid) ;; plugins (swap! state/state md/dissoc-in [:plugin/installed-plugins pid]) ;; commands (clear-commands! pid)))) (.on "unlink-plugin" (fn [pid] (let [pid (keyword pid)] (ipc/ipc "uninstallMarketPlugin" (name pid))))) (.on "beforereload" (fn [^js pl] (let [pid (.-id pl)] (clear-commands! pid) (unregister-plugin-themes pid false)))) (.on "disabled" (fn [pid] (clear-commands! pid) (unregister-plugin-themes pid))) (.on "theme-changed" (fn [^js themes] (swap! state/state assoc :plugin/installed-themes (vec (mapcat (fn [[pid vs]] (mapv #(assoc % :pid pid) (bean/->clj vs))) (bean/->clj themes)))))) (.on "theme-selected" (fn [^js opts] (let [opts (bean/->clj opts) url (:url opts) mode (:mode opts)] (when mode (state/set-theme! mode)) (state/set-state! :plugin/selected-theme url)))) (.on "settings-changed" (fn [id ^js settings] (let [id (keyword id)] (when (and settings (contains? (:plugin/installed-plugins @state/state) id)) (update-plugin-settings id (bean/->clj settings))))))) default-plugins (get-user-default-plugins) _ (.register js/LSPluginCore (bean/->js (if (seq default-plugins) default-plugins [])) true)]) #(do (state/set-state! :plugin/indicator-text "END") (callback)))) (defn setup! "setup plugin core handler" [callback] (if (not lsp-enabled?) (callback) (init-plugins! callback)))