浏览代码

Enhance/more ns plugin api (#4828)

* improve(plugin): WIP add settings schema

* improve(plugin): add identity for settings modal

* improve(plugin): WIP add settings input

* fix(ui): scrollbar overlay of modal panel content

* improve(plugin): WIP add more render types of setting item

* improve(plugin): WIP polish settings items

* improve(plugin): WIP settings list of plugins

* improve(plugin): more settings types & polish releated ui

* fix(plugin): sometimes disable plugin not work

* improve(plugin): polish ui of plugin settings

* fix(dev): warning of lint

* improve(plugin): add api of settings changed

* chore: build libs core

* fix(ui): width of settings panel wrap

* improve(plugin): separate layouts data from settings aio file

* imporve(plugin): container size of single plugin settings

* fix: add missing state

* improve(plugin): add Git ns

* improve(plugin): git related api

* improve(api): type of git result

* chore: build libs core

* fix(dev): kondo lint

* fix(plugin): use cdn sdk when js entry

* chore: build libs core

* fix(plugin): env condition

* improve(plugin): add UI ns

* fix(api): arguments of datascript query

* enhance(api): manageable message instance of UI tools

* enhance(api): WIP add experiments api

* enhance(api): WIP add resources state of plugin

* improve(plugin): add status of loading script resources

* improve(plugin): more opts for script loader

* improve(plugin): WIP add fenced code renderer hook

* improve(plugin): fenced code renderer hook

* fix(plugin): resource root path of plugin fs location

* imporve(plugin): support local files for loading scripts

* improve(plugin): types of expirements api

* fix: typo of class

* enhance(api): add namespace related apis

* enhance(api): add linked refrences related apis

* enhance(plugin): add sample links to related api comments

* improve(plugin): add db changed hook & optimize strategy of caller for hooks

* improve(plugin): compatible commands registration for old sdk

* improve(plugin): collect user sdk version for plugin local

* improve(plugin): add internal callable apis for user sdk

* chore(plugin): missing files & bump libs version

* improve(plugin): compatiable for old sdk about hook messaging optimization

* improve(plugin): db hook optimization for old sdk

* enhance(ux): auto focus searchbar when open plugins list

* improve(plugin): api of a hook from specific block changed event

* improve(plugin): api of db block change hook

* improve(plugin): add show bracket user config of api

* improve(plugin): api of db block change hook

* fix(api): toggle collapsed of block

* improve(api): try to init grpah with git before exec git commands

* improve(plugin): attributes of sandbox container

* improve(dev): support register command with keybinding

* improve(plugin): add api of register shortcut command

* fix(plugin): reubild slash commands when new command registration

* fix(dev): lint

* improve(dev): lint script of libs codebase

* chore(dev): remove useless codes

* improve(plugin):sanitize path string of plugin repo value

* fix(plugin): rebuild commands list when unregister a plugin

* fix(ui): overflow width of query result table

* chore: rebuild libs core

* improve(plugin): add assets related apis

* chore: rebuild libs core

* improve(plugin): support replace state of into block in page api

* improve(plugin): prepend/append child block in page

* improve(plugin): polished exceptions message of plugin update/install

* fix(plugin): update settings within gui

* improve(ux): debounce change event of input for plugin settings gui

* chore: rebuild libs core

* enhance(plugin): catch exception of hook plugin
Charlie 3 年之前
父节点
当前提交
79bc33e1e3
共有 35 个文件被更改,包括 1861 次插入866 次删除
  1. 4 0
      libs/.prettierrc.js
  2. 6 2
      libs/package.json
  3. 132 103
      libs/src/LSPlugin.caller.ts
  4. 273 189
      libs/src/LSPlugin.core.ts
  5. 19 23
      libs/src/LSPlugin.shadow.ts
  6. 297 114
      libs/src/LSPlugin.ts
  7. 258 111
      libs/src/LSPlugin.user.ts
  8. 7 0
      libs/src/callable.apis.ts
  9. 146 94
      libs/src/helpers.ts
  10. 67 0
      libs/src/modules/LSPlugin.Experiments.ts
  11. 18 22
      libs/src/modules/LSPlugin.Storage.ts
  12. 84 61
      libs/src/postmate/index.ts
  13. 4 0
      libs/webpack.config.js
  14. 10 0
      libs/yarn.lock
  15. 0 1
      resources/js/lsplugin.core.js
  16. 7 0
      src/electron/electron/git.cljs
  17. 11 0
      src/electron/electron/handler.cljs
  18. 9 6
      src/electron/electron/plugin.cljs
  19. 51 13
      src/electron/electron/utils.js
  20. 4 1
      src/main/frontend/components/block.cljs
  21. 56 35
      src/main/frontend/components/block.css
  22. 1 1
      src/main/frontend/components/hierarchy.cljs
  23. 22 17
      src/main/frontend/components/plugins.cljs
  24. 0 1
      src/main/frontend/components/plugins.css
  25. 7 7
      src/main/frontend/components/plugins_settings.cljs
  26. 6 0
      src/main/frontend/components/settings.css
  27. 16 3
      src/main/frontend/handler/events.cljs
  28. 5 3
      src/main/frontend/handler/notification.cljs
  29. 5 1
      src/main/frontend/handler/page.cljs
  30. 82 18
      src/main/frontend/handler/plugin.cljs
  31. 4 0
      src/main/frontend/handler/shell.cljs
  32. 9 4
      src/main/frontend/loader.cljs
  33. 9 3
      src/main/frontend/modules/outliner/pipeline.cljs
  34. 45 3
      src/main/frontend/state.cljs
  35. 187 30
      src/main/logseq/api.cljs

+ 4 - 0
libs/.prettierrc.js

@@ -0,0 +1,4 @@
+module.exports = {
+  ...require('prettier-config-standard'),
+  trailingComma: 'es5'
+}

+ 6 - 2
libs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@logseq/libs",
-  "version": "0.0.1-alpha.35",
+  "version": "0.0.2",
   "description": "Logseq SDK libraries",
   "main": "dist/lsplugin.user.js",
   "typings": "index.d.ts",
@@ -10,7 +10,9 @@
     "dev:user": "npm run build:user -- --mode development --watch",
     "build:core": "webpack --config webpack.config.core.js --mode production",
     "dev:core": "npm run build:core -- --mode development --watch",
-    "build": "tsc && rm dist/*.js && npm run build:user"
+    "build": "tsc && rm dist/*.js && npm run build:user",
+    "lint": "prettier --check \"src/**/*.{ts, js}\"",
+    "fix": "prettier --write \"src/**/*.{ts, js}\""
   },
   "dependencies": {
     "csstype": "3.0.8",
@@ -26,6 +28,8 @@
     "@types/debug": "^4.1.5",
     "@types/dompurify": "^2.2.1",
     "@types/lodash-es": "^4.17.4",
+    "prettier": "^2.6.2",
+    "prettier-config-standard": "^5.0.0",
     "ts-loader": "^8.0.17",
     "typescript": "^4.2.2",
     "webpack": "^5.24.3",

+ 132 - 103
libs/src/LSPlugin.caller.ts

@@ -33,14 +33,16 @@ class LSPluginCaller extends EventEmitter {
   private _status?: 'pending' | 'timeout'
   private _userModel: any = {}
 
-  private _call?: (type: string, payload: any, actor?: DeferredActor) => Promise<any>
+  private _call?: (
+    type: string,
+    payload: any,
+    actor?: DeferredActor
+  ) => Promise<any>
   private _callUserModel?: (type: string, payload: any) => Promise<any>
 
   private _debugTag = ''
 
-  constructor (
-    private _pluginLocal: PluginLocal | null
-  ) {
+  constructor(private _pluginLocal: PluginLocal | null) {
     super()
 
     if (_pluginLocal) {
@@ -48,7 +50,7 @@ class LSPluginCaller extends EventEmitter {
     }
   }
 
-  async connectToChild () {
+  async connectToChild() {
     if (this._connected) return
 
     const { shadow } = this._pluginLocal!
@@ -61,7 +63,7 @@ class LSPluginCaller extends EventEmitter {
   }
 
   // run in sandbox
-  async connectToParent (userModel = {}) {
+  async connectToParent(userModel = {}) {
     if (this._connected) return
 
     const caller = this
@@ -75,8 +77,14 @@ class LSPluginCaller extends EventEmitter {
     const model: any = this._extendUserModel({
       [LSPMSG_READY]: async (baseInfo) => {
         // dynamically setup common msg handler
-        model[LSPMSGFn(baseInfo?.pid)] = ({ type, payload }: { type: string, payload: any }) => {
-          debug(`[call from host (_call)] ${this._debugTag}`, type, payload)
+        model[LSPMSGFn(baseInfo?.pid)] = ({
+          type,
+          payload,
+        }: {
+          type: string
+          payload: any
+        }) => {
+          debug(`[host (_call) -> *user] ${this._debugTag}`, type, payload)
           // host._call without async
           caller.emit(type, payload)
         }
@@ -95,7 +103,10 @@ class LSPluginCaller extends EventEmitter {
       },
 
       [LSPMSG]: async ({ ns, type, payload }: any) => {
-        debug(`[call from host (async)] ${this._debugTag}`, ns, type, payload)
+        debug(
+          `[host (async) -> *user] ${this._debugTag} ns=${ns} type=${type}`,
+          payload
+        )
 
         if (ns && ns.startsWith('hook')) {
           caller.emit(`${ns}:${type}`, payload)
@@ -106,7 +117,7 @@ class LSPluginCaller extends EventEmitter {
       },
 
       [LSPMSG_SYNC]: ({ _sync, result }: any) => {
-        debug(`[sync reply] #${_sync}`, result)
+        debug(`[sync host -> *user] #${_sync}`, result)
 
         if (syncActors.has(_sync)) {
           const actor = syncActors.get(_sync)
@@ -123,7 +134,7 @@ class LSPluginCaller extends EventEmitter {
         }
       },
 
-      ...userModel
+      ...userModel,
     })
 
     if (isShadowMode) {
@@ -136,81 +147,88 @@ class LSPluginCaller extends EventEmitter {
 
     this._status = 'pending'
 
-    await handshake.then((refParent: ChildAPI) => {
-      this._child = refParent
-      this._connected = true
+    await handshake
+      .then((refParent: ChildAPI) => {
+        this._child = refParent
+        this._connected = true
 
-      this._call = async (type, payload = {}, actor) => {
-        if (actor) {
-          const tag = ++syncTag
-          syncActors.set(tag, actor)
-          payload._sync = tag
+        this._call = async (type, payload = {}, actor) => {
+          if (actor) {
+            const tag = ++syncTag
+            syncActors.set(tag, actor)
+            payload._sync = tag
 
-          actor.setTag(`async call #${tag}`)
-          debug('async call #', tag)
-        }
+            actor.setTag(`async call #${tag}`)
+            debug('async call #', tag)
+          }
 
-        refParent.emit(LSPMSGFn(model.baseInfo.id), { type, payload })
+          refParent.emit(LSPMSGFn(model.baseInfo.id), { type, payload })
 
-        return actor?.promise as Promise<any>
-      }
+          return actor?.promise as Promise<any>
+        }
 
-      this._callUserModel = async (type, payload) => {
-        try {
-          model[type](payload)
-        } catch (e) {
-          debug(`[model method] #${type} not existed`)
+        this._callUserModel = async (type, payload) => {
+          try {
+            model[type](payload)
+          } catch (e) {
+            debug(`[model method] #${type} not existed`)
+          }
         }
-      }
 
-      // actors GC
-      syncGCTimer = setInterval(() => {
-        if (syncActors.size > 100) {
-          for (const [k, v] of syncActors) {
-            if (v.settled) {
-              syncActors.delete(k)
+        // actors GC
+        syncGCTimer = setInterval(() => {
+          if (syncActors.size > 100) {
+            for (const [k, v] of syncActors) {
+              if (v.settled) {
+                syncActors.delete(k)
+              }
             }
           }
-        }
-      }, 1000 * 60 * 30)
-    }).finally(() => {
-      this._status = undefined
-    })
+        }, 1000 * 60 * 30)
+      })
+      .finally(() => {
+        this._status = undefined
+      })
 
     await readyDeferred.promise
 
     return model.baseInfo
   }
 
-  async call (type: any, payload: any = {}) {
+  async call(type: any, payload: any = {}) {
     return this._call?.call(this, type, payload)
   }
 
-  async callAsync (type: any, payload: any = {}) {
+  // only for callable apis for sdk user
+  async callAsync(type: any, payload: any = {}) {
     const actor = deferred(1000 * 10)
     return this._call?.call(this, type, payload, actor)
   }
 
-  async callUserModel (type: string, payload: any = {}) {
+  async callUserModel(type: string, payload: any = {}) {
     return this._callUserModel?.call(this, type, payload)
   }
 
   // run in host
-  async _setupIframeSandbox () {
+  async _setupIframeSandbox() {
     const pl = this._pluginLocal!
     const id = pl.id
+    const domId = `${id}_lsp_main`
     const url = new URL(pl.options.entry!)
 
-    url.searchParams
-      .set(`__v__`, IS_DEV ? Date.now().toString() : pl.options.version)
+    url.searchParams.set(
+      `__v__`,
+      IS_DEV ? Date.now().toString() : pl.options.version
+    )
 
     // clear zombie sandbox
-    const zb = document.querySelector(`#${id}`)
+    const zb = document.querySelector(`#${domId}`)
     if (zb) zb.parentElement.removeChild(zb)
 
     const cnt = document.createElement('div')
     cnt.classList.add('lsp-iframe-sandbox-container')
-    cnt.id = id
+    cnt.id = domId
+    cnt.dataset.pid = id
 
     // TODO: apply any container layout data
     try {
@@ -219,20 +237,24 @@ class LSPluginCaller extends EventEmitter {
         cnt.dataset.inited_layout = 'true'
         const { width, height, left, top } = mainLayoutInfo
         Object.assign(cnt.style, {
-          width: width + 'px', height: height + 'px',
-          left: left + 'px', top: top + 'px'
+          width: width + 'px',
+          height: height + 'px',
+          left: left + 'px',
+          top: top + 'px',
         })
       }
     } catch (e) {
-      console.error("[Restore Layout Error]", e)
+      console.error('[Restore Layout Error]', e)
     }
 
     document.body.appendChild(cnt)
 
     const pt = new Postmate({
-      id: id + '_iframe', container: cnt, url: url.href,
+      id: id + '_iframe',
+      container: cnt,
+      url: url.href,
       classListArray: ['lsp-iframe-sandbox'],
-      model: { baseInfo: JSON.parse(JSON.stringify(pl.toJSON())) }
+      model: { baseInfo: JSON.parse(JSON.stringify(pl.toJSON())) },
     })
 
     let handshake = pt.sendHandshake()
@@ -246,52 +268,58 @@ class LSPluginCaller extends EventEmitter {
         reject(new Error(`handshake Timeout`))
       }, 3 * 1000) // 3secs
 
-      handshake.then((refChild: ParentAPI) => {
-        this._parent = refChild
-        this._connected = true
-        this.emit('connected')
+      handshake
+        .then((refChild: ParentAPI) => {
+          this._parent = refChild
+          this._connected = true
+          this.emit('connected')
 
-        refChild.on(LSPMSGFn(pl.id), ({ type, payload }: any) => {
-          debug(`[call from plugin] `, type, payload)
+          refChild.on(LSPMSGFn(pl.id), ({ type, payload }: any) => {
+            debug(`[user -> *host] `, type, payload)
 
-          this._pluginLocal?.emit(type, payload || {})
-        })
+            this._pluginLocal?.emit(type, payload || {})
+          })
 
-        this._call = async (...args: any) => {
-          // parent all will get message before handshaked
-          await refChild.call(LSPMSGFn(pl.id), {
-            type: args[0], payload: Object.assign(args[1] || {}, {
-              $$pid: pl.id
+          this._call = async (...args: any) => {
+            // parent all will get message before handshaked
+            await refChild.call(LSPMSGFn(pl.id), {
+              type: args[0],
+              payload: Object.assign(args[1] || {}, {
+                $$pid: pl.id,
+              }),
             })
-          })
-        }
+          }
 
-        this._callUserModel = async (type, payload: any) => {
-          if (type.startsWith(FLAG_AWAIT)) {
-            // TODO: attach payload with method call
-            return await refChild.get(type.replace(FLAG_AWAIT, ''))
-          } else {
-            refChild.call(type, payload)
+          this._callUserModel = async (type, payload: any) => {
+            if (type.startsWith(FLAG_AWAIT)) {
+              // TODO: attach payload with method call
+              return await refChild.get(type.replace(FLAG_AWAIT, ''))
+            } else {
+              refChild.call(type, payload)
+            }
           }
-        }
 
-        resolve(null)
-      }).catch(e => {
-        reject(e)
-      }).finally(() => {
-        clearTimeout(timer)
-      })
-    }).catch(e => {
-      debug('[iframe sandbox] error', e)
-      throw e
-    }).finally(() => {
-      this._status = undefined
+          resolve(null)
+        })
+        .catch((e) => {
+          reject(e)
+        })
+        .finally(() => {
+          clearTimeout(timer)
+        })
     })
+      .catch((e) => {
+        debug('[iframe sandbox] error', e)
+        throw e
+      })
+      .finally(() => {
+        this._status = undefined
+      })
   }
 
-  async _setupShadowSandbox () {
+  async _setupShadowSandbox() {
     const pl = this._pluginLocal!
-    const shadow = this._shadow = new LSPluginShadowFrame(pl)
+    const shadow = (this._shadow = new LSPluginShadowFrame(pl))
 
     try {
       this._status = 'pending'
@@ -305,9 +333,12 @@ class LSPluginCaller extends EventEmitter {
         actor && (payload.actor = actor)
 
         // @ts-ignore Call in same thread
-        this._pluginLocal?.emit(type, Object.assign(payload, {
-          $$pid: pl.id
-        }))
+        this._pluginLocal?.emit(
+          type,
+          Object.assign(payload, {
+            $$pid: pl.id,
+          })
+        )
 
         return actor?.promise
       }
@@ -334,31 +365,31 @@ class LSPluginCaller extends EventEmitter {
     }
   }
 
-  _extendUserModel (model: any) {
+  _extendUserModel(model: any) {
     return Object.assign(this._userModel, model)
   }
 
-  _getSandboxIframeContainer () {
+  _getSandboxIframeContainer() {
     return this._parent?.frame.parentNode as HTMLDivElement
   }
 
-  _getSandboxShadowContainer () {
+  _getSandboxShadowContainer() {
     return this._shadow?.frame.parentNode as HTMLDivElement
   }
 
-  _getSandboxIframeRoot () {
+  _getSandboxIframeRoot() {
     return this._parent?.frame
   }
 
-  _getSandboxShadowRoot () {
+  _getSandboxShadowRoot() {
     return this._shadow?.frame
   }
 
-  set debugTag (value: string) {
+  set debugTag(value: string) {
     this._debugTag = value
   }
 
-  async destroy () {
+  async destroy() {
     let root: HTMLElement = null
     if (this._parent) {
       root = this._getSandboxIframeContainer()
@@ -374,6 +405,4 @@ class LSPluginCaller extends EventEmitter {
   }
 }
 
-export {
-  LSPluginCaller
-}
+export { LSPluginCaller }

文件差异内容过多而无法显示
+ 273 - 189
libs/src/LSPlugin.core.ts


+ 19 - 23
libs/src/LSPlugin.shadow.ts

@@ -5,16 +5,16 @@ import { LSPluginUser } from './LSPlugin.user'
 // @ts-ignore
 const { importHTML, createSandboxContainer } = window.QSandbox || {}
 
-function userFetch (url, opts) {
+function userFetch(url, opts) {
   if (!url.startsWith('http')) {
     url = url.replace('file://', '')
     return new Promise(async (resolve, reject) => {
       try {
         const content = await window.apis.doAction(['readFile', url])
         resolve({
-          text () {
+          text() {
             return content
-          }
+          },
         })
       } catch (e) {
         console.error(e)
@@ -32,9 +32,7 @@ class LSPluginShadowFrame extends EventEmitter<'mounted' | 'unmounted'> {
   private _loaded = false
   private _unmountFns: Array<() => Promise<void>> = []
 
-  constructor (
-    private _pluginLocal: PluginLocal
-  ) {
+  constructor(private _pluginLocal: PluginLocal) {
     super()
 
     _pluginLocal._dispose(() => {
@@ -42,20 +40,20 @@ class LSPluginShadowFrame extends EventEmitter<'mounted' | 'unmounted'> {
     })
   }
 
-  async load () {
+  async load() {
     const { name, entry } = this._pluginLocal.options
 
     if (this.loaded || !entry) return
 
-    const { template, execScripts } = await importHTML(entry, { fetch: userFetch })
+    const { template, execScripts } = await importHTML(entry, {
+      fetch: userFetch,
+    })
 
     this._mount(template, document.body)
 
-    const sandbox = createSandboxContainer(
-      name, {
-        elementGetter: () => this._root?.firstChild,
-      }
-    )
+    const sandbox = createSandboxContainer(name, {
+      elementGetter: () => this._root?.firstChild,
+    })
 
     const global = sandbox.instance.proxy as any
 
@@ -75,8 +73,8 @@ class LSPluginShadowFrame extends EventEmitter<'mounted' | 'unmounted'> {
     this._loaded = true
   }
 
-  _mount (content: string, container: HTMLElement) {
-    const frame = this._frame = document.createElement('div')
+  _mount(content: string, container: HTMLElement) {
+    const frame = (this._frame = document.createElement('div'))
     frame.classList.add('lsp-shadow-sandbox')
     frame.id = this._pluginLocal.id
 
@@ -88,29 +86,27 @@ class LSPluginShadowFrame extends EventEmitter<'mounted' | 'unmounted'> {
     this.emit('mounted')
   }
 
-  _unmount () {
+  _unmount() {
     for (const fn of this._unmountFns) {
       fn && fn.call(null)
     }
   }
 
-  destroy () {
+  destroy() {
     this.frame?.parentNode?.removeChild(this.frame)
   }
 
-  get loaded (): boolean {
+  get loaded(): boolean {
     return this._loaded
   }
 
-  get document () {
+  get document() {
     return this._root?.firstChild as HTMLElement
   }
 
-  get frame (): HTMLElement {
+  get frame(): HTMLElement {
     return this._frame!
   }
 }
 
-export {
-  LSPluginShadowFrame
-}
+export { LSPluginShadowFrame }

+ 297 - 114
libs/src/LSPlugin.ts

@@ -2,6 +2,7 @@ import EventEmitter from 'eventemitter3'
 import * as CSS from 'csstype'
 import { LSPluginCaller } from './LSPlugin.caller'
 import { LSPluginFileStorage } from './modules/LSPlugin.Storage'
+import { LSPluginExperiments } from './modules/LSPlugin.Experiments'
 
 export type PluginLocalIdentity = string
 
@@ -14,7 +15,7 @@ export type ThemeOptions = {
   [key: string]: any
 }
 
-export type StyleString = string;
+export type StyleString = string
 export type StyleOptions = {
   key?: string
   style: StyleString
@@ -76,7 +77,7 @@ export interface LSPluginBaseInfo {
   settings: {
     disabled: boolean
     [key: string]: any
-  },
+  }
 
   [key: string]: any
 }
@@ -86,15 +87,26 @@ export type IHookEvent = {
 }
 
 export type IUserOffHook = () => void
-export type IUserHook<E = any, R = IUserOffHook> = (callback: (e: IHookEvent & E) => void) => IUserOffHook
-export type IUserSlotHook<E = any> = (callback: (e: IHookEvent & UISlotIdentity & E) => void) => void
+export type IUserHook<E = any, R = IUserOffHook> = (
+  callback: (e: IHookEvent & E) => void
+) => IUserOffHook
+export type IUserSlotHook<E = any> = (
+  callback: (e: IHookEvent & UISlotIdentity & E) => void
+) => void
 
 export type EntityID = number
 export type BlockUUID = string
 export type BlockUUIDTuple = ['uuid', BlockUUID]
 
-export type IEntityID = { id: EntityID }
-export type IBatchBlock = { content: string, properties?: Record<string, any>, children?: Array<IBatchBlock> }
+export type IEntityID = { id: EntityID; [key: string]: any }
+export type IBatchBlock = {
+  content: string
+  properties?: Record<string, any>
+  children?: Array<IBatchBlock>
+}
+export type IDatom = [e: number, a: string, v: any, t: number, added: boolean]
+
+export type IGitResult = { stdout: string; stderr: string; exitCode: number }
 
 export interface AppUserInfo {
   [key: string]: any
@@ -111,6 +123,9 @@ export interface AppUserConfigs {
   preferredLanguage: string
   preferredWorkflow: string
 
+  currentGraph: string
+  showBracket: boolean
+
   [key: string]: any
 }
 
@@ -145,7 +160,7 @@ export interface BlockEntity {
   container?: string
   file?: IEntityID
   level?: number
-  meta?: { timestamps: any, properties: any, startPos: number, endPos: number }
+  meta?: { timestamps: any; properties: any; startPos: number; endPos: number }
   title?: Array<any>
 
   [key: string]: any
@@ -163,6 +178,7 @@ export interface PageEntity {
 
   file?: IEntityID
   namespace?: IEntityID
+  children?: Array<PageEntity>
   format?: 'markdown' | 'org'
   journalDay?: number
 }
@@ -171,18 +187,26 @@ export type BlockIdentity = BlockUUID | Pick<BlockEntity, 'uuid'>
 export type BlockPageName = string
 export type PageIdentity = BlockPageName | BlockIdentity
 export type SlashCommandActionCmd =
-  'editor/input'
+  | 'editor/input'
   | 'editor/hook'
   | 'editor/clear-current-slash'
   | 'editor/restore-saved-cursor'
 export type SlashCommandAction = [cmd: SlashCommandActionCmd, ...args: any]
 export type SimpleCommandCallback = (e: IHookEvent) => void
-export type BlockCommandCallback = (e: IHookEvent & { uuid: BlockUUID }) => Promise<void>
-export type BlockCursorPosition = { left: number, top: number, height: number, pos: number, rect: DOMRect }
+export type BlockCommandCallback = (
+  e: IHookEvent & { uuid: BlockUUID }
+) => Promise<void>
+export type BlockCursorPosition = {
+  left: number
+  top: number
+  height: number
+  pos: number
+  rect: DOMRect
+}
 
 export type SimpleCommandKeybinding = {
-  mode?: 'global' | 'non-editing' | 'editing',
-  binding: string,
+  mode?: 'global' | 'non-editing' | 'editing'
+  binding: string
   mac?: string // special for Mac OS
 }
 
@@ -198,48 +222,50 @@ export type SettingSchemaDesc = {
 }
 
 export type ExternalCommandType =
-  'logseq.command/run' |
-  'logseq.editor/cycle-todo' |
-  'logseq.editor/down' |
-  'logseq.editor/up' |
-  'logseq.editor/expand-block-children' |
-  'logseq.editor/collapse-block-children' |
-  'logseq.editor/open-file-in-default-app' |
-  'logseq.editor/open-file-in-directory' |
-  'logseq.editor/select-all-blocks' |
-  'logseq.editor/toggle-open-blocks' |
-  'logseq.editor/zoom-in' |
-  'logseq.editor/zoom-out' |
-  'logseq.editor/indent' |
-  'logseq.editor/outdent' |
-  'logseq.editor/copy' |
-  'logseq.editor/cut' |
-  'logseq.go/home' |
-  'logseq.go/journals' |
-  'logseq.go/keyboard-shortcuts' |
-  'logseq.go/next-journal' |
-  'logseq.go/prev-journal' |
-  'logseq.go/search' |
-  'logseq.go/search-in-page' |
-  'logseq.go/tomorrow' |
-  'logseq.go/backward' |
-  'logseq.go/forward' |
-  'logseq.search/re-index' |
-  'logseq.sidebar/clear' |
-  'logseq.sidebar/open-today-page' |
-  'logseq.ui/goto-plugins' |
-  'logseq.ui/select-theme-color' |
-  'logseq.ui/toggle-brackets' |
-  'logseq.ui/toggle-cards' |
-  'logseq.ui/toggle-contents' |
-  'logseq.ui/toggle-document-mode' |
-  'logseq.ui/toggle-help' |
-  'logseq.ui/toggle-left-sidebar' |
-  'logseq.ui/toggle-right-sidebar' |
-  'logseq.ui/toggle-settings' |
-  'logseq.ui/toggle-theme' |
-  'logseq.ui/toggle-wide-mode' |
-  'logseq.command-palette/toggle'
+  | 'logseq.command/run'
+  | 'logseq.editor/cycle-todo'
+  | 'logseq.editor/down'
+  | 'logseq.editor/up'
+  | 'logseq.editor/expand-block-children'
+  | 'logseq.editor/collapse-block-children'
+  | 'logseq.editor/open-file-in-default-app'
+  | 'logseq.editor/open-file-in-directory'
+  | 'logseq.editor/select-all-blocks'
+  | 'logseq.editor/toggle-open-blocks'
+  | 'logseq.editor/zoom-in'
+  | 'logseq.editor/zoom-out'
+  | 'logseq.editor/indent'
+  | 'logseq.editor/outdent'
+  | 'logseq.editor/copy'
+  | 'logseq.editor/cut'
+  | 'logseq.go/home'
+  | 'logseq.go/journals'
+  | 'logseq.go/keyboard-shortcuts'
+  | 'logseq.go/next-journal'
+  | 'logseq.go/prev-journal'
+  | 'logseq.go/search'
+  | 'logseq.go/search-in-page'
+  | 'logseq.go/tomorrow'
+  | 'logseq.go/backward'
+  | 'logseq.go/forward'
+  | 'logseq.search/re-index'
+  | 'logseq.sidebar/clear'
+  | 'logseq.sidebar/open-today-page'
+  | 'logseq.ui/goto-plugins'
+  | 'logseq.ui/select-theme-color'
+  | 'logseq.ui/toggle-brackets'
+  | 'logseq.ui/toggle-cards'
+  | 'logseq.ui/toggle-contents'
+  | 'logseq.ui/toggle-document-mode'
+  | 'logseq.ui/toggle-help'
+  | 'logseq.ui/toggle-left-sidebar'
+  | 'logseq.ui/toggle-right-sidebar'
+  | 'logseq.ui/toggle-settings'
+  | 'logseq.ui/toggle-theme'
+  | 'logseq.ui/toggle-wide-mode'
+  | 'logseq.command-palette/toggle'
+
+export type UserProxyTags = 'app' | 'editor' | 'db' | 'git' | 'ui' | 'assets'
 
 /**
  * App level APIs
@@ -253,25 +279,33 @@ export interface IAppProxy {
   registerCommand: (
     type: string,
     opts: {
-      key: string,
-      label: string,
-      desc?: string,
-      palette?: boolean,
+      key: string
+      label: string
+      desc?: string
+      palette?: boolean
       keybinding?: SimpleCommandKeybinding
     },
-    action: SimpleCommandCallback) => void
+    action: SimpleCommandCallback
+  ) => void
 
   registerCommandPalette: (
     opts: {
-      key: string,
-      label: string,
+      key: string
+      label: string
       keybinding?: SimpleCommandKeybinding
     },
-    action: SimpleCommandCallback) => void
+    action: SimpleCommandCallback
+  ) => void
+
+  registerCommandShortcut: (
+    keybinding: SimpleCommandKeybinding,
+    action: SimpleCommandCallback
+  ) => void
 
   invokeExternalCommand: (
     type: ExternalCommandType,
-    ...args: Array<any>) => Promise<void>
+    ...args: Array<any>
+  ) => Promise<void>
 
   /**
    * Get state from app store
@@ -284,8 +318,7 @@ export interface IAppProxy {
    * ```
    * @param path
    */
-  getStateFromStore:
-    <T = any>(path: string | Array<string>) => Promise<T>
+  getStateFromStore: <T = any>(path: string | Array<string>) => Promise<T>
 
   // native
   relaunch: () => Promise<void>
@@ -293,6 +326,7 @@ export interface IAppProxy {
   openExternalLink: (url: string) => Promise<void>
 
   /**
+   * @deprecated Using `logseq.Git.execCommand`
    * @link https://github.com/desktop/dugite/blob/master/docs/api/exec.md
    * @param args
    */
@@ -302,12 +336,23 @@ export interface IAppProxy {
   getCurrentGraph: () => Promise<AppGraphInfo | null>
 
   // router
-  pushState: (k: string, params?: Record<string, any>, query?: Record<string, any>) => void
-  replaceState: (k: string, params?: Record<string, any>, query?: Record<string, any>) => void
+  pushState: (
+    k: string,
+    params?: Record<string, any>,
+    query?: Record<string, any>
+  ) => void
+  replaceState: (
+    k: string,
+    params?: Record<string, any>,
+    query?: Record<string, any>
+  ) => void
 
   // ui
   queryElementById: (id: string) => Promise<string | boolean>
-  showMsg: (content: string, status?: 'success' | 'warning' | 'error' | string) => void
+  showMsg: (
+    content: string,
+    status?: 'success' | 'warning' | 'error' | string
+  ) => void
   setZoomFactor: (factor: number) => void
   setFullScreen: (flag: boolean | 'toggle') => void
   setLeftSidebarVisible: (flag: boolean | 'toggle') => void
@@ -315,7 +360,7 @@ export interface IAppProxy {
 
   registerUIItem: (
     type: 'toolbar' | 'pagebar',
-    opts: { key: string, template: string }
+    opts: { key: string; template: string }
   ) => void
 
   registerPageMenuItem: (
@@ -331,6 +376,7 @@ export interface IAppProxy {
   /**
    * provide ui slot to block `renderer` macro for `{{renderer arg1, arg2}}`
    *
+   * @example https://github.com/logseq/logseq-plugin-samples/tree/master/logseq-pomodoro-timer
    * @example
    * ```ts
    * // e.g. {{renderer :h1, hello world, green}}
@@ -347,11 +393,17 @@ export interface IAppProxy {
    * })
    * ```
    */
-  onMacroRendererSlotted: IUserSlotHook<{ payload: { arguments: Array<string>, uuid: string, [key: string]: any } }>
+  onMacroRendererSlotted: IUserSlotHook<{
+    payload: { arguments: Array<string>; uuid: string; [key: string]: any }
+  }>
 
   onPageHeadActionsSlotted: IUserSlotHook
-  onRouteChanged: IUserHook<{ path: string, template: string }>
+  onRouteChanged: IUserHook<{ path: string; template: string }>
   onSidebarVisibleChanged: IUserHook<{ visible: boolean }>
+
+  // internal
+  _installPluginHook: (pid: string, hook: string) => void
+  _uninstallPluginHook: (pid: string, hookOrAll: string | boolean) => void
 }
 
 /**
@@ -360,10 +412,12 @@ export interface IAppProxy {
 export interface IEditorProxy extends Record<string, any> {
   /**
    * register a custom command which will be added to the Logseq slash command list
-   *
    * @param tag - displayed name of command
    * @param action - can be a single callback function to run when the command is called, or an array of fixed commands with arguments
    *
+   *
+   * @example https://github.com/logseq/logseq-plugin-samples/tree/master/logseq-slash-commands
+   *
    * @example
    * ```ts
    * logseq.Editor.registerSlashCommand("Say Hi", () => {
@@ -398,9 +452,6 @@ export interface IEditorProxy extends Record<string, any> {
 
   checkEditing: () => Promise<BlockUUID | boolean>
 
-  /**
-   * insert a string at the current cursor
-   */
   insertAtEditingCursor: (content: string) => Promise<void>
 
   restoreEditingCursor: () => Promise<void>
@@ -435,16 +486,52 @@ export interface IEditorProxy extends Record<string, any> {
    */
   getPageBlocksTree: (srcPage: PageIdentity) => Promise<Array<BlockEntity>>
 
+  /**
+   * get all page/block linked references
+   * @param srcPage
+   */
+  getPageLinkedReferences: (
+    srcPage: PageIdentity
+  ) => Promise<Array<[page: PageEntity, blocks: Array<BlockEntity>]> | null>
+
+  /**
+   * get flatten pages from top namespace
+   * @param namespace
+   */
+  getPagesFromNamespace: (
+    namespace: BlockPageName
+  ) => Promise<Array<PageEntity> | null>
+
+  /**
+   * construct pages tree from namespace pages
+   * @param namespace
+   */
+  getPagesTreeFromNamespace: (
+    namespace: BlockPageName
+  ) => Promise<Array<PageEntity> | null>
+
+  /**
+   * @example https://github.com/logseq/logseq-plugin-samples/tree/master/logseq-reddit-hot-news
+   *
+   * @param srcBlock
+   * @param content
+   * @param opts
+   */
   insertBlock: (
     srcBlock: BlockIdentity,
     content: string,
-    opts?: Partial<{ before: boolean; sibling: boolean; isPageBlock: boolean; properties: {} }>
+    opts?: Partial<{
+      before: boolean
+      sibling: boolean
+      isPageBlock: boolean
+      properties: {}
+    }>
   ) => Promise<BlockEntity | null>
 
   insertBatchBlock: (
     srcBlock: BlockIdentity,
     batch: IBatchBlock | Array<IBatchBlock>,
-    opts?: Partial<{ before: boolean, sibling: boolean }>
+    opts?: Partial<{ before: boolean; sibling: boolean }>
   ) => Promise<Array<BlockEntity> | null>
 
   updateBlock: (
@@ -453,9 +540,7 @@ export interface IEditorProxy extends Record<string, any> {
     opts?: Partial<{ properties: {} }>
   ) => Promise<void>
 
-  removeBlock: (
-    srcBlock: BlockIdentity
-  ) => Promise<void>
+  removeBlock: (srcBlock: BlockIdentity) => Promise<void>
 
   getBlock: (
     srcBlock: BlockIdentity | EntityID,
@@ -475,17 +560,24 @@ export interface IEditorProxy extends Record<string, any> {
   createPage: (
     pageName: BlockPageName,
     properties?: {},
-    opts?: Partial<{ redirect: boolean, createFirstBlock: boolean, format: BlockEntity['format'], journal: boolean }>
+    opts?: Partial<{
+      redirect: boolean
+      createFirstBlock: boolean
+      format: BlockEntity['format']
+      journal: boolean
+    }>
   ) => Promise<PageEntity | null>
 
-  deletePage: (
-    pageName: BlockPageName
-  ) => Promise<void>
+  deletePage: (pageName: BlockPageName) => Promise<void>
 
   renamePage: (oldName: string, newName: string) => Promise<void>
 
   getAllPages: (repo?: string) => Promise<any>
 
+  prependBlockInPage: (page: PageIdentity, content: string, opts?: Partial<{ properties: {} }>) => Promise<BlockEntity | null>
+
+  appendBlockInPage: (page: PageIdentity, content: string, opts?: Partial<{ properties: {} }>) => Promise<BlockEntity | null>
+
   getPreviousSiblingBlock: (
     srcBlock: BlockIdentity
   ) => Promise<BlockEntity | null>
@@ -514,13 +606,22 @@ export interface IEditorProxy extends Record<string, any> {
 
   scrollToBlockInPage: (
     pageName: BlockPageName,
-    blockId: BlockIdentity
+    blockId: BlockIdentity,
+    opts?: { replaceState: boolean }
   ) => void
 
   openInRightSidebar: (uuid: BlockUUID) => void
 
-  // events
-  onInputSelectionEnd: IUserHook<{ caret: any, point: { x: number, y: number }, start: number, end: number, text: string }>
+  /**
+   * @example https://github.com/logseq/logseq-plugin-samples/tree/master/logseq-a-translator
+   */
+  onInputSelectionEnd: IUserHook<{
+    caret: any
+    point: { x: number; y: number }
+    start: number
+    end: number
+    text: string
+  }>
 }
 
 /**
@@ -537,17 +638,88 @@ export interface IDBProxy {
   /**
    * Run a datascript query
    */
-  datascriptQuery: <T = any>(query: string) => Promise<T>
+  datascriptQuery: <T = any>(query: string, ...inputs: Array<any>) => Promise<T>
+
+  /**
+   * Hook all transaction data of DB
+   */
+  onChanged: IUserHook<{
+    blocks: Array<BlockEntity>
+    txData: Array<IDatom>
+    txMeta?: { outlinerOp: string; [key: string]: any }
+  }>
+
+  /**
+   * Subscribe a specific block changed event
+   */
+  onBlockChanged(
+    uuid: BlockUUID,
+    callback: (
+      block: BlockEntity,
+      txData: Array<IDatom>,
+      txMeta?: { outlinerOp: string; [key: string]: any }
+    ) => void
+  ): IUserOffHook
+}
+
+/**
+ * Git related APIS
+ */
+export interface IGitProxy {
+  /**
+   * @link https://github.com/desktop/dugite/blob/master/docs/api/exec.md
+   * @param args
+   */
+  execCommand: (args: string[]) => Promise<IGitResult>
+
+  loadIgnoreFile: () => Promise<string>
+  saveIgnoreFile: (content: string) => Promise<void>
+}
+
+/**
+ * UI related APIs
+ */
+export type UIMsgOptions = {
+  key: string
+  timeout: number // milliseconds. `0` indicate that keep showing
+}
+
+export type UIMsgKey = UIMsgOptions['key']
+
+export interface IUIProxy {
+  showMsg: (
+    content: string,
+    status?: 'success' | 'warning' | 'error' | string,
+    opts?: Partial<UIMsgOptions>
+  ) => Promise<UIMsgKey>
+
+  closeMsg: (key: UIMsgKey) => void
+}
+
+/**
+ * Assets related APIs
+ */
+export interface IAssetsProxy {
+  listFilesOfCurrentGraph(
+    exts: string | string[]
+  ): Promise<{
+    path: string
+    size: number
+    accessTime: number
+    modifiedTime: number
+    changeTime: number
+    birthTime: number
+  }>
 }
 
 export interface ILSPluginThemeManager extends EventEmitter {
   themes: Map<PluginLocalIdentity, Array<ThemeOptions>>
 
-  registerTheme (id: PluginLocalIdentity, opt: ThemeOptions): Promise<void>
+  registerTheme(id: PluginLocalIdentity, opt: ThemeOptions): Promise<void>
 
-  unregisterTheme (id: PluginLocalIdentity): Promise<void>
+  unregisterTheme(id: PluginLocalIdentity): Promise<void>
 
-  selectTheme (opt?: ThemeOptions): Promise<void>
+  selectTheme(opt?: ThemeOptions): Promise<void>
 }
 
 export type LSPluginUserEvents = 'ui:visible:changed' | 'settings:changed'
@@ -578,14 +750,14 @@ export interface ILSPluginUser extends EventEmitter<LSPluginUserEvents> {
    *
    * @param model - same as the model in `provideModel`
    */
-  ready (model?: Record<string, any>): Promise<any>
+  ready(model?: Record<string, any>): Promise<any>
 
   /**
    * @param callback - a function to run when the main Logseq app is ready
    */
-  ready (callback?: (e: any) => void | {}): Promise<any>
+  ready(callback?: (e: any) => void | {}): Promise<any>
 
-  ready (
+  ready(
     model?: Record<string, any>,
     callback?: (e: any) => void | {}
   ): Promise<any>
@@ -604,34 +776,31 @@ export interface ILSPluginUser extends EventEmitter<LSPluginUserEvents> {
    * })
    * ```
    */
-  provideModel (model: Record<string, any>): this
+  provideModel(model: Record<string, any>): this
 
   /**
    * Set the theme for the main Logseq app
    */
-  provideTheme (theme: ThemeOptions): this
+  provideTheme(theme: ThemeOptions): this
 
   /**
    * Inject custom css for the main Logseq app
    *
+   * @example https://github.com/logseq/logseq-plugin-samples/tree/master/logseq-awesome-fonts
    * @example
    * ```ts
    *   logseq.provideStyle(`
    *    @import url("https://at.alicdn.com/t/font_2409735_r7em724douf.css");
    *  )
    * ```
-   *
-   * @example
-   * ```ts
-   *
-   * ```
    */
-  provideStyle (style: StyleString | StyleOptions): this
+  provideStyle(style: StyleString | StyleOptions): this
 
   /**
    * Inject custom UI at specific DOM node.
    * Event handlers can not be passed by string, so you need to create them in `provideModel`
    *
+   * @example https://github.com/logseq/logseq-plugin-samples/tree/master/logseq-a-translator
    * @example
    * ```ts
    * logseq.provideUI({
@@ -645,23 +814,34 @@ export interface ILSPluginUser extends EventEmitter<LSPluginUserEvents> {
    * })
    * ```
    */
-  provideUI (ui: UIOptions): this
+  provideUI(ui: UIOptions): this
 
-  useSettingsSchema (schemas: Array<SettingSchemaDesc>): this
+  /**
+   * @example https://github.com/logseq/logseq-plugin-samples/tree/master/logseq-awesome-fonts
+   *
+   * @param schemas
+   */
+  useSettingsSchema(schemas: Array<SettingSchemaDesc>): this
 
-  updateSettings (attrs: Record<string, any>): void
+  /**
+   * @example https://github.com/logseq/logseq-plugin-samples/tree/master/logseq-awesome-fonts
+   *
+   * @param attrs
+   */
+  updateSettings(attrs: Record<string, any>): void
 
-  onSettingsChanged<T = any> (cb: (a: T, b: T) => void): IUserOffHook
+  onSettingsChanged<T = any>(cb: (a: T, b: T) => void): IUserOffHook
 
-  showSettingsUI (): void
+  showSettingsUI(): void
 
-  hideSettingsUI (): void
+  hideSettingsUI(): void
 
-  setMainUIAttrs (attrs: Record<string, any>): void
+  setMainUIAttrs(attrs: Record<string, any>): void
 
   /**
    * Set the style for the plugin's UI
    *
+   * @example https://github.com/logseq/logseq-plugin-samples/tree/master/logseq-awesome-fonts
    * @example
    * ```ts
    * logseq.setMainUIInlineStyle({
@@ -670,30 +850,33 @@ export interface ILSPluginUser extends EventEmitter<LSPluginUserEvents> {
    * })
    * ```
    */
-  setMainUIInlineStyle (style: CSS.Properties): void
+  setMainUIInlineStyle(style: CSS.Properties): void
 
   /**
    * show the plugin's UI
    */
-  showMainUI (opts?: { autoFocus: boolean }): void
+  showMainUI(opts?: { autoFocus: boolean }): void
 
   /**
    * hide the plugin's UI
    */
-  hideMainUI (opts?: { restoreEditingCursor: boolean }): void
+  hideMainUI(opts?: { restoreEditingCursor: boolean }): void
 
   /**
    * toggle the plugin's UI
    */
-  toggleMainUI (): void
+  toggleMainUI(): void
 
   isMainUIVisible: boolean
 
-  resolveResourceFullUrl (filePath: string): string
+  resolveResourceFullUrl(filePath: string): string
 
   App: IAppProxy & Record<string, any>
   Editor: IEditorProxy & Record<string, any>
   DB: IDBProxy
+  Git: IGitProxy
+  UI: IUIProxy
 
   FileStorage: LSPluginFileStorage
+  Experiments: LSPluginExperiments
 }

+ 258 - 111
libs/src/LSPlugin.user.ts

@@ -1,7 +1,13 @@
-import { deepMerge, mergeSettingsWithSchema, safetyPathJoin } from './helpers'
+import {
+  deepMerge,
+  mergeSettingsWithSchema,
+  safeSnakeCase,
+  safetyPathJoin,
+} from './helpers'
 import { LSPluginCaller } from './LSPlugin.caller'
 import {
-  IAppProxy, IDBProxy,
+  IAppProxy,
+  IDBProxy,
   IEditorProxy,
   ILSPluginUser,
   LSPluginBaseInfo,
@@ -10,19 +16,33 @@ import {
   BlockCommandCallback,
   StyleString,
   ThemeOptions,
-  UIOptions, IHookEvent, BlockIdentity,
+  UIOptions,
+  IHookEvent,
+  BlockIdentity,
   BlockPageName,
-  UIContainerAttrs, SimpleCommandCallback, SimpleCommandKeybinding, SettingSchemaDesc, IUserOffHook
+  UIContainerAttrs,
+  SimpleCommandCallback,
+  SimpleCommandKeybinding,
+  SettingSchemaDesc,
+  IUserOffHook,
+  IGitProxy,
+  IUIProxy,
+  UserProxyTags,
+  BlockUUID,
+  BlockEntity,
+  IDatom,
+  IAssetsProxy,
 } from './LSPlugin'
 import Debug from 'debug'
 import * as CSS from 'csstype'
-import { snakeCase } from 'snake-case'
 import EventEmitter from 'eventemitter3'
 import { LSPluginFileStorage } from './modules/LSPlugin.Storage'
+import { LSPluginExperiments } from './modules/LSPlugin.Experiments'
 
 declare global {
   interface Window {
     __LSP__HOST__: boolean
+    logseq: LSPluginUser
   }
 }
 
@@ -34,14 +54,14 @@ const debug = Debug('LSPlugin:user')
  * @param opts
  * @param action
  */
-function registerSimpleCommand (
+function registerSimpleCommand(
   this: LSPluginUser,
   type: string,
   opts: {
-    key: string,
-    label: string,
-    desc?: string,
-    palette?: boolean,
+    key: string
+    label: string
+    desc?: string
+    palette?: boolean
     keybinding?: SimpleCommandKeybinding
   },
   action: SimpleCommandCallback
@@ -57,40 +77,62 @@ function registerSimpleCommand (
 
   this.caller?.call(`api:call`, {
     method: 'register-plugin-simple-command',
-    args: [this.baseInfo.id, [{ key, label, type, desc, keybinding }, ['editor/hook', eventKey]], palette]
+    args: [
+      this.baseInfo.id,
+      [{ key, label, type, desc, keybinding }, ['editor/hook', eventKey]],
+      palette,
+    ],
   })
 }
 
 const app: Partial<IAppProxy> = {
   registerCommand: registerSimpleCommand,
 
-  registerCommandPalette (
-    opts: { key: string; label: string, keybinding?: SimpleCommandKeybinding },
-    action: SimpleCommandCallback) {
-
+  registerCommandPalette(
+    opts: { key: string; label: string; keybinding?: SimpleCommandKeybinding },
+    action: SimpleCommandCallback
+  ) {
     const { key, label, keybinding } = opts
-    const group = 'global-palette-command'
+    const group = '$palette$'
 
     return registerSimpleCommand.call(
-      this, group,
+      this,
+      group,
       { key, label, palette: true, keybinding },
-      action)
+      action
+    )
+  },
+
+  registerCommandShortcut(
+    keybinding: SimpleCommandKeybinding,
+    action: SimpleCommandCallback
+  ) {
+    const { binding } = keybinding
+    const group = '$shortcut$'
+    const key = group + safeSnakeCase(binding)
+
+    return registerSimpleCommand.call(
+      this,
+      group,
+      { key, palette: false, keybinding },
+      action
+    )
   },
 
-  registerUIItem (
+  registerUIItem(
     type: 'toolbar' | 'pagebar',
-    opts: { key: string, template: string }
+    opts: { key: string; template: string }
   ) {
     const pid = this.baseInfo.id
     // opts.key = `${pid}_${opts.key}`
 
     this.caller?.call(`api:call`, {
       method: 'register-plugin-ui-item',
-      args: [pid, type, opts]
+      args: [pid, type, opts],
     })
   },
 
-  registerPageMenuItem (
+  registerPageMenuItem(
     this: LSPluginUser,
     tag: string,
     action: (e: IHookEvent & { page: string }) => void
@@ -103,29 +145,34 @@ const app: Partial<IAppProxy> = {
     const label = tag
     const type = 'page-menu-item'
 
-    registerSimpleCommand.call(this,
-      type, {
-        key, label
-      }, action)
+    registerSimpleCommand.call(
+      this,
+      type,
+      {
+        key,
+        label,
+      },
+      action
+    )
   },
 
-  setFullScreen (flag) {
+  setFullScreen(flag) {
     const sf = (...args) => this._callWin('setFullScreen', ...args)
 
     if (flag === 'toggle') {
-      this._callWin('isFullScreen').then(r => {
+      this._callWin('isFullScreen').then((r) => {
         r ? sf() : sf(true)
       })
     } else {
       flag ? sf(true) : sf()
     }
-  }
+  },
 }
 
 let registeredCmdUid = 0
 
 const editor: Partial<IEditorProxy> = {
-  registerSlashCommand (
+  registerSlashCommand(
     this: LSPluginUser,
     tag: string,
     actions: BlockCommandCallback | Array<SlashCommandAction>
@@ -136,7 +183,7 @@ const editor: Partial<IEditorProxy> = {
       actions = [
         ['editor/clear-current-slash', false],
         ['editor/restore-saved-cursor'],
-        ['editor/hook', actions]
+        ['editor/hook', actions],
       ]
     }
 
@@ -169,11 +216,11 @@ const editor: Partial<IEditorProxy> = {
 
     this.caller?.call(`api:call`, {
       method: 'register-plugin-slash-command',
-      args: [this.baseInfo.id, [tag, actions]]
+      args: [this.baseInfo.id, [tag, actions]],
     })
   },
 
-  registerBlockContextMenuItem (
+  registerBlockContextMenuItem(
     this: LSPluginUser,
     tag: string,
     action: BlockCommandCallback
@@ -186,30 +233,68 @@ const editor: Partial<IEditorProxy> = {
     const label = tag
     const type = 'block-context-menu-item'
 
-    registerSimpleCommand.call(this,
-      type, {
-        key, label
-      }, action)
+    registerSimpleCommand.call(
+      this,
+      type,
+      {
+        key,
+        label,
+      },
+      action
+    )
   },
 
-  scrollToBlockInPage (
+  scrollToBlockInPage(
     this: LSPluginUser,
     pageName: BlockPageName,
-    blockId: BlockIdentity
+    blockId: BlockIdentity,
+    opts?: { replaceState: boolean }
   ) {
     const anchor = `block-content-` + blockId
-    this.App.pushState(
-      'page',
-      { name: pageName },
-      { anchor }
-    )
-  }
+    if (opts?.replaceState) {
+      this.App.replaceState('page', { name: pageName }, { anchor })
+    } else {
+      this.App.pushState('page', { name: pageName }, { anchor })
+    }
+  },
 }
 
-const db: Partial<IDBProxy> = {}
+const db: Partial<IDBProxy> = {
+  onBlockChanged(
+    this: LSPluginUser,
+    uuid: BlockUUID,
+    callback: (
+      block: BlockEntity,
+      txData: Array<IDatom>,
+      txMeta?: { outlinerOp: string; [p: string]: any }
+    ) => void
+  ): IUserOffHook {
+    const pid = this.baseInfo.id
+    const hook = `hook:db:${safeSnakeCase(`block:${uuid}`)}`
+    const aBlockChange = ({ block, txData, txMeta }) => {
+      if (block.uuid !== uuid) {
+        return
+      }
+
+      callback(block, txData, txMeta)
+    }
+
+    this.caller.on(hook, aBlockChange)
+    this.App._installPluginHook(pid, hook)
+
+    return () => {
+      this.caller.off(hook, aBlockChange)
+      this.App._uninstallPluginHook(pid, hook)
+    }
+  },
+}
+
+const git: Partial<IGitProxy> = {}
+const ui: Partial<IUIProxy> = {}
+const assets: Partial<IAssetsProxy> = {}
 
 type uiState = {
-  key?: number,
+  key?: number
   visible: boolean
 }
 
@@ -219,7 +304,12 @@ const KEY_MAIN_UI = 0
  * User plugin instance
  * @public
  */
-export class LSPluginUser extends EventEmitter<LSPluginUserEvents> implements ILSPluginUser {
+export class LSPluginUser
+  extends EventEmitter<LSPluginUserEvents>
+  implements ILSPluginUser {
+  // @ts-ignore
+  private _version: string = LIB_VERSION
+  private _debugTag: string = ''
   private _settingsSchema?: Array<SettingSchemaDesc>
   private _connected: boolean = false
 
@@ -229,7 +319,8 @@ export class LSPluginUser extends EventEmitter<LSPluginUserEvents> implements IL
    */
   private _ui = new Map<number, uiState>()
 
-  private readonly _fileStorage: LSPluginFileStorage
+  private _mFileStorage: LSPluginFileStorage
+  private _mExperiments: LSPluginExperiments
 
   /**
    * handler of before unload plugin
@@ -241,7 +332,7 @@ export class LSPluginUser extends EventEmitter<LSPluginUserEvents> implements IL
    * @param _baseInfo
    * @param _caller
    */
-  constructor (
+  constructor(
     private _baseInfo: LSPluginBaseInfo,
     private _caller: LSPluginCaller
   ) {
@@ -264,22 +355,16 @@ export class LSPluginUser extends EventEmitter<LSPluginUserEvents> implements IL
       const cb = this._beforeunloadCallback
 
       try {
-        cb && await cb(rest)
+        cb && (await cb(rest))
         actor?.resolve(null)
       } catch (e) {
         console.debug(`${_caller.debugTag} [beforeunload] `, e)
         actor?.reject(e)
       }
     })
-
-    // modules
-    this._fileStorage = new LSPluginFileStorage(this)
   }
 
-  async ready (
-    model?: any,
-    callback?: any
-  ) {
+  async ready(model?: any, callback?: any) {
     if (this._connected) return
 
     try {
@@ -296,7 +381,8 @@ export class LSPluginUser extends EventEmitter<LSPluginUserEvents> implements IL
 
       if (this._settingsSchema) {
         baseInfo.settings = mergeSettingsWithSchema(
-          baseInfo.settings, this._settingsSchema
+          baseInfo.settings,
+          this._settingsSchema
         )
 
         // TODO: sync host settings schema
@@ -304,50 +390,56 @@ export class LSPluginUser extends EventEmitter<LSPluginUserEvents> implements IL
       }
 
       if (baseInfo?.id) {
-        this._caller.debugTag = `#${baseInfo.id} [${baseInfo.name}]`
+        this._debugTag =
+          this._caller.debugTag = `#${baseInfo.id} [${baseInfo.name}]`
       }
 
+      await this._execCallableAPIAsync('setSDKMetadata', {
+        version: this._version,
+      })
+
       callback && callback.call(this, baseInfo)
     } catch (e) {
-      console.error('[LSPlugin Ready Error]', e)
+      console.error(`${this._debugTag} [Ready Error]`, e)
     }
   }
 
-  ensureConnected () {
+  ensureConnected() {
     if (!this._connected) {
       throw new Error('not connected')
     }
   }
 
-  beforeunload (callback: (e: any) => Promise<void>): void {
+  beforeunload(callback: (e: any) => Promise<void>): void {
     if (typeof callback !== 'function') return
     this._beforeunloadCallback = callback
   }
 
-  provideModel (model: Record<string, any>) {
+  provideModel(model: Record<string, any>) {
     this.caller._extendUserModel(model)
     return this
   }
 
-  provideTheme (theme: ThemeOptions) {
+  provideTheme(theme: ThemeOptions) {
     this.caller.call('provider:theme', theme)
     return this
   }
 
-  provideStyle (style: StyleString) {
+  provideStyle(style: StyleString) {
     this.caller.call('provider:style', style)
     return this
   }
 
-  provideUI (ui: UIOptions) {
+  provideUI(ui: UIOptions) {
     this.caller.call('provider:ui', ui)
     return this
   }
 
-  useSettingsSchema (schema: Array<SettingSchemaDesc>) {
+  useSettingsSchema(schema: Array<SettingSchemaDesc>) {
     if (this.connected) {
       this.caller.call('settings:schema', {
-        schema, isSync: true
+        schema,
+        isSync: true,
       })
     }
 
@@ -355,48 +447,56 @@ export class LSPluginUser extends EventEmitter<LSPluginUserEvents> implements IL
     return this
   }
 
-  updateSettings (attrs: Record<string, any>) {
+  updateSettings(attrs: Record<string, any>) {
     this.caller.call('settings:update', attrs)
     // TODO: update associated baseInfo settings
   }
 
-  onSettingsChanged<T = any> (cb: (a: T, b: T) => void): IUserOffHook {
+  onSettingsChanged<T = any>(cb: (a: T, b: T) => void): IUserOffHook {
     const type = 'settings:changed'
     this.on(type, cb)
     return () => this.off(type, cb)
   }
 
-  showSettingsUI () {
+  showSettingsUI() {
     this.caller.call('settings:visible:changed', { visible: true })
   }
 
-  hideSettingsUI () {
+  hideSettingsUI() {
     this.caller.call('settings:visible:changed', { visible: false })
   }
 
-  setMainUIAttrs (attrs: Partial<UIContainerAttrs>): void {
+  setMainUIAttrs(attrs: Partial<UIContainerAttrs>): void {
     this.caller.call('main-ui:attrs', attrs)
   }
 
-  setMainUIInlineStyle (style: CSS.Properties): void {
+  setMainUIInlineStyle(style: CSS.Properties): void {
     this.caller.call('main-ui:style', style)
   }
 
-  hideMainUI (opts?: { restoreEditingCursor: boolean }): void {
-    const payload = { key: KEY_MAIN_UI, visible: false, cursor: opts?.restoreEditingCursor }
+  hideMainUI(opts?: { restoreEditingCursor: boolean }): void {
+    const payload = {
+      key: KEY_MAIN_UI,
+      visible: false,
+      cursor: opts?.restoreEditingCursor,
+    }
     this.caller.call('main-ui:visible', payload)
     this.emit('ui:visible:changed', payload)
     this._ui.set(payload.key, payload)
   }
 
-  showMainUI (opts?: { autoFocus: boolean }): void {
-    const payload = { key: KEY_MAIN_UI, visible: true, autoFocus: opts?.autoFocus }
+  showMainUI(opts?: { autoFocus: boolean }): void {
+    const payload = {
+      key: KEY_MAIN_UI,
+      visible: true,
+      autoFocus: opts?.autoFocus,
+    }
     this.caller.call('main-ui:visible', payload)
     this.emit('ui:visible:changed', payload)
     this._ui.set(payload.key, payload)
   }
 
-  toggleMainUI (): void {
+  toggleMainUI(): void {
     const payload = { key: KEY_MAIN_UI, toggle: true }
     const state = this._ui.get(payload.key)
     if (state && state.visible) {
@@ -406,28 +506,32 @@ export class LSPluginUser extends EventEmitter<LSPluginUserEvents> implements IL
     }
   }
 
-  get isMainUIVisible (): boolean {
+  get version(): string {
+    return this._version
+  }
+
+  get isMainUIVisible(): boolean {
     const state = this._ui.get(KEY_MAIN_UI)
     return Boolean(state && state.visible)
   }
 
-  get connected (): boolean {
+  get connected(): boolean {
     return this._connected
   }
 
-  get baseInfo (): LSPluginBaseInfo {
+  get baseInfo(): LSPluginBaseInfo {
     return this._baseInfo
   }
 
-  get settings () {
+  get settings() {
     return this.baseInfo?.settings
   }
 
-  get caller (): LSPluginCaller {
+  get caller(): LSPluginCaller {
     return this._caller
   }
 
-  resolveResourceFullUrl (filePath: string) {
+  resolveResourceFullUrl(filePath: string) {
     this.ensureConnected()
     if (!filePath) return
     filePath = filePath.replace(/^[.\\/]+/, '')
@@ -437,20 +541,17 @@ export class LSPluginUser extends EventEmitter<LSPluginUserEvents> implements IL
   /**
    * @internal
    */
-  _makeUserProxy (
-    target: any,
-    tag?: 'app' | 'editor' | 'db'
-  ) {
+  _makeUserProxy(target: any, tag?: UserProxyTags) {
     const that = this
     const caller = this.caller
 
     return new Proxy(target, {
-      get (target: any, propKey, receiver) {
+      get(target: any, propKey, receiver) {
         const origMethod = target[propKey]
 
         return function (this: any, ...args: any) {
           if (origMethod) {
-            const ret = origMethod.apply(that, args)
+            const ret = origMethod.apply(that, args.concat(tag))
             if (ret !== PROXY_CONTINUE) return
           }
 
@@ -462,50 +563,96 @@ export class LSPluginUser extends EventEmitter<LSPluginUserEvents> implements IL
               const f = hookMatcher[0].toLowerCase()
               const s = hookMatcher.input!
               const e = s.slice(f.length)
+              const isOff = f === 'off'
+              const pid = that.baseInfo.id
 
-              const type = `hook:${tag}:${snakeCase(e)}`
+              const type = `hook:${tag}:${safeSnakeCase(e)}`
               const handler = args[0]
               caller[f](type, handler)
-              return f !== 'off' ? () => (caller.off(type, handler)) : void 0
+
+              if (isOff) {
+                return () => {
+                  caller.off(type, handler)
+                  that.App._uninstallPluginHook(pid, type)
+                }
+              } else {
+                return that.App._installPluginHook(pid, type)
+              }
             }
           }
 
+          let method = propKey as string
+
+          if ((['git', 'ui', 'assets'] as UserProxyTags[]).includes(tag)) {
+            method = tag + '_' + method
+          }
+
           // Call host
           return caller.callAsync(`api:call`, {
-            tag, method: propKey, args: args
+            tag,
+            method,
+            args: args,
           })
         }
-      }
+      },
     })
   }
 
-  /**
-   * @param args
-   */
-  _callWin (...args) {
+  _execCallableAPIAsync(method, ...args) {
     return this._caller.callAsync(`api:call`, {
-      method: '_callMainWin',
-      args: args
+      method,
+      args,
+    })
+  }
+
+  _execCallableAPI(method, ...args) {
+    this._caller.call(`api:call`, {
+      method,
+      args,
     })
   }
 
+  _callWin(...args) {
+    return this._execCallableAPIAsync(`_callMainWin`, ...args)
+  }
+
   /**
    * The interface methods of {@link IAppProxy}
    */
-  get App (): IAppProxy {
+  get App(): IAppProxy {
     return this._makeUserProxy(app, 'app')
   }
 
-  get Editor (): IEditorProxy {
+  get Editor(): IEditorProxy {
     return this._makeUserProxy(editor, 'editor')
   }
 
-  get DB (): IDBProxy {
-    return this._makeUserProxy(db)
+  get DB(): IDBProxy {
+    return this._makeUserProxy(db, 'db')
+  }
+
+  get Git(): IGitProxy {
+    return this._makeUserProxy(git, 'git')
   }
 
-  get FileStorage (): LSPluginFileStorage {
-    return this._fileStorage
+  get UI(): IUIProxy {
+    return this._makeUserProxy(ui, 'ui')
+  }
+
+  get Assets(): IAssetsProxy {
+    return this._makeUserProxy(assets, 'assets')
+  }
+
+  get FileStorage(): LSPluginFileStorage {
+    let m = this._mFileStorage
+    if (!m) m = this._mFileStorage = new LSPluginFileStorage(this)
+    return m
+  }
+
+  get Experiments(): LSPluginExperiments {
+    let m = this._mExperiments
+    if (!m) m = this._mExperiments = new LSPluginExperiments(this)
+    return m
   }
 }
 
@@ -514,15 +661,15 @@ export * from './LSPlugin'
 /**
  * @internal
  */
-export function setupPluginUserInstance (
+export function setupPluginUserInstance(
   pluginBaseInfo: LSPluginBaseInfo,
   pluginCaller: LSPluginCaller
 ) {
   return new LSPluginUser(pluginBaseInfo, pluginCaller)
 }
 
-if (window.__LSP__HOST__ == null) { // Entry of iframe mode
+// entry of iframe mode
+if (window.__LSP__HOST__ == null) {
   const caller = new LSPluginCaller(null)
-  // @ts-ignore
   window.logseq = setupPluginUserInstance({} as any, caller)
 }

+ 7 - 0
libs/src/callable.apis.ts

@@ -0,0 +1,7 @@
+import { PluginLocal } from './LSPlugin.core'
+
+export function setSDKMetadata(this: PluginLocal, data: any) {
+  if (this?.sdk && data) {
+    this.sdk = Object.assign({}, this.sdk, data)
+  }
+}

+ 146 - 94
libs/src/helpers.ts

@@ -1,13 +1,10 @@
 import { SettingSchemaDesc, StyleString, UIOptions } from './LSPlugin'
 import { PluginLocal } from './LSPlugin.core'
-import { snakeCase } from 'snake-case'
 import * as nodePath from 'path'
 import DOMPurify from 'dompurify'
 import { merge } from 'lodash-es'
-
-interface IObject {
-  [key: string]: any;
-}
+import { snakeCase } from 'snake-case'
+import * as callables from './callable.apis'
 
 declare global {
   interface Window {
@@ -16,7 +13,8 @@ declare global {
   }
 }
 
-export const path = navigator.platform.toLowerCase() === 'win32' ? nodePath.win32 : nodePath.posix
+export const path =
+  navigator.platform.toLowerCase() === 'win32' ? nodePath.win32 : nodePath.posix
 export const IS_DEV = process.env.NODE_ENV === 'development'
 export const PROTOCOL_FILE = 'file://'
 export const PROTOCOL_LSP = 'lsp://'
@@ -24,17 +22,21 @@ export const URL_LSP = PROTOCOL_LSP + 'logseq.io/'
 
 let _appPathRoot
 
-export async function getAppPathRoot (): Promise<string> {
+// TODO: snakeCase of lodash is incompatible with `snake-case`
+export const safeSnakeCase = snakeCase
+
+export async function getAppPathRoot(): Promise<string> {
   if (_appPathRoot) {
     return _appPathRoot
   }
 
-  return (_appPathRoot =
-      await invokeHostExportedApi('_callApplication', 'getAppPath')
-  )
+  return (_appPathRoot = await invokeHostExportedApi(
+    '_callApplication',
+    'getAppPath'
+  ))
 }
 
-export async function getSDKPathRoot (): Promise<string> {
+export async function getSDKPathRoot(): Promise<string> {
   if (IS_DEV) {
     // TODO: cache in preference file
     return localStorage.getItem('LSP_DEV_SDK_ROOT') || 'http://localhost:8080'
@@ -45,24 +47,24 @@ export async function getSDKPathRoot (): Promise<string> {
   return safetyPathJoin(appPathRoot, 'js')
 }
 
-export function isObject (item: any) {
-  return (item === Object(item) && !Array.isArray(item))
+export function isObject(item: any) {
+  return item === Object(item) && !Array.isArray(item)
 }
 
 export const deepMerge = merge
 
-export function genID () {
+export function genID() {
   // Math.random should be unique because of its seeding algorithm.
   // Convert it to base 36 (numbers + letters), and grab the first 9 characters
   // after the decimal.
   return '_' + Math.random().toString(36).substr(2, 9)
 }
 
-export function ucFirst (str: string) {
+export function ucFirst(str: string) {
   return str.charAt(0).toUpperCase() + str.slice(1)
 }
 
-export function withFileProtocol (path: string) {
+export function withFileProtocol(path: string) {
   if (!path) return ''
   const reg = /^(http|file|lsp)/
 
@@ -73,7 +75,7 @@ export function withFileProtocol (path: string) {
   return path
 }
 
-export function safetyPathJoin (basePath: string, ...parts: Array<string>) {
+export function safetyPathJoin(basePath: string, ...parts: Array<string>) {
   try {
     const url = new URL(basePath)
     if (!url.origin) throw new Error(null)
@@ -84,7 +86,7 @@ export function safetyPathJoin (basePath: string, ...parts: Array<string>) {
   }
 }
 
-export function safetyPathNormalize (basePath: string) {
+export function safetyPathNormalize(basePath: string) {
   if (!basePath?.match(/^(http?|lsp|assets):/)) {
     basePath = path.normalize(basePath)
   }
@@ -95,7 +97,7 @@ export function safetyPathNormalize (basePath: string) {
  * @param timeout milliseconds
  * @param tag string
  */
-export function deferred<T = any> (timeout?: number, tag?: string) {
+export function deferred<T = any>(timeout?: number, tag?: string) {
   let resolve: any, reject: any
   let settled = false
   const timeFn = (r: Function) => {
@@ -112,41 +114,48 @@ export function deferred<T = any> (timeout?: number, tag?: string) {
 
     if (timeout) {
       // @ts-ignore
-      timeout = setTimeout(() => reject(new Error(`[deferred timeout] ${tag}`)), timeout)
+      timeout = setTimeout(
+        () => reject(new Error(`[deferred timeout] ${tag}`)),
+        timeout
+      )
     }
   })
 
   return {
     created: Date.now(),
-    setTag: (t: string) => tag = t,
-    resolve, reject, promise,
-    get settled () {
+    setTag: (t: string) => (tag = t),
+    resolve,
+    reject,
+    promise,
+    get settled() {
       return settled
-    }
+    },
   }
 }
 
-export function invokeHostExportedApi (
-  method: string,
-  ...args: Array<any>
-) {
-  method = method?.startsWith('_call') ? method :
-    method?.replace(/^[_$]+/, '')
-  const method1 = snakeCase(method)
+export function invokeHostExportedApi(method: string, ...args: Array<any>) {
+  method = method?.startsWith('_call') ? method : method?.replace(/^[_$]+/, '')
+  const method1 = safeSnakeCase(method)
 
-  // @ts-ignore
-  const logseqHostExportedApi = window.logseq?.api || {}
+  const logseqHostExportedApi = Object.assign(
+    // @ts-ignore
+    window.logseq?.api || {},
+    callables
+  )
 
-  const fn = logseqHostExportedApi[method1] || window.apis[method1] ||
-    logseqHostExportedApi[method] || window.apis[method]
+  const fn =
+    logseqHostExportedApi[method1] ||
+    window.apis[method1] ||
+    logseqHostExportedApi[method] ||
+    window.apis[method]
 
   if (!fn) {
     throw new Error(`Not existed method #${method}`)
   }
-  return typeof fn !== 'function' ? fn : fn.apply(null, args)
+  return typeof fn !== 'function' ? fn : fn.apply(this, args)
 }
 
-export function setupIframeSandbox (
+export function setupIframeSandbox(
   props: Record<string, any>,
   target: HTMLElement
 ) {
@@ -165,7 +174,7 @@ export function setupIframeSandbox (
   }
 }
 
-export function setupInjectedStyle (
+export function setupInjectedStyle(
   style: StyleString,
   attrs: Record<string, any>
 ) {
@@ -180,9 +189,10 @@ export function setupInjectedStyle (
   el = document.createElement('style')
   el.textContent = style
 
-  attrs && Object.entries(attrs).forEach(([k, v]) => {
-    el.setAttribute(k, v)
-  })
+  attrs &&
+    Object.entries(attrs).forEach(([k, v]) => {
+      el.setAttribute(k, v)
+    })
 
   document.head.append(el)
 
@@ -193,11 +203,11 @@ export function setupInjectedStyle (
 
 const injectedUIEffects = new Map<string, () => void>()
 
-export function setupInjectedUI (
+export function setupInjectedUI(
   this: PluginLocal,
   ui: UIOptions,
   attrs: Record<string, string>,
-  initialCallback?: (e: { el: HTMLElement, float: boolean }) => void
+  initialCallback?: (e: { el: HTMLElement; float: boolean }) => void
 ) {
   let slot: string = ''
   let selector: string
@@ -217,21 +227,32 @@ export function setupInjectedUI (
   const id = `${ui.key}-${slot}-${pl.id}`
   const key = `${ui.key}--${pl.id}`
 
-  const target = float ? document.body : (selector && document.querySelector(selector))
+  const target = float
+    ? document.body
+    : selector && document.querySelector(selector)
   if (!target) {
-    console.error(`${this.debugTag} can not resolve selector target ${selector}`)
+    console.error(
+      `${this.debugTag} can not resolve selector target ${selector}`
+    )
     return
   }
 
   if (ui.template) {
     // safe template
-    ui.template = DOMPurify.sanitize(
-      ui.template, {
-        ADD_TAGS: ['iframe'],
-        ALLOW_UNKNOWN_PROTOCOLS: true,
-        ADD_ATTR: ['allow', 'src', 'allowfullscreen', 'frameborder', 'scrolling', 'target']
-      })
-  } else { // remove ui
+    ui.template = DOMPurify.sanitize(ui.template, {
+      ADD_TAGS: ['iframe'],
+      ALLOW_UNKNOWN_PROTOCOLS: true,
+      ADD_ATTR: [
+        'allow',
+        'src',
+        'allowfullscreen',
+        'frameborder',
+        'scrolling',
+        'target',
+      ],
+    })
+  } else {
+    // remove ui
     injectedUIEffects.get(id)?.call(null)
     return
   }
@@ -243,20 +264,23 @@ export function setupInjectedUI (
     content.innerHTML = ui.template
 
     // update attributes
-    attrs && Object.entries(attrs).forEach(([k, v]) => {
-      el.setAttribute(k, v)
-    })
+    attrs &&
+      Object.entries(attrs).forEach(([k, v]) => {
+        el.setAttribute(k, v)
+      })
 
     let positionDirty = el.dataset.dx != null
-    ui.style && Object.entries(ui.style).forEach(([k, v]) => {
-      if (positionDirty && [
-        'left', 'top', 'bottom', 'right', 'width', 'height'].includes(k)
-      ) {
-        return
-      }
-
-      el.style[k] = v
-    })
+    ui.style &&
+      Object.entries(ui.style).forEach(([k, v]) => {
+        if (
+          positionDirty &&
+          ['left', 'top', 'bottom', 'right', 'width', 'height'].includes(k)
+        ) {
+          return
+        }
+
+        el.style[k] = v
+      })
     return
   }
 
@@ -275,13 +299,15 @@ export function setupInjectedUI (
   // TODO: enhance template
   content.innerHTML = ui.template
 
-  attrs && Object.entries(attrs).forEach(([k, v]) => {
-    el.setAttribute(k, v)
-  })
+  attrs &&
+    Object.entries(attrs).forEach(([k, v]) => {
+      el.setAttribute(k, v)
+    })
 
-  ui.style && Object.entries(ui.style).forEach(([k, v]) => {
-    el.style[k] = v
-  })
+  ui.style &&
+    Object.entries(ui.style).forEach(([k, v]) => {
+      el.style[k] = v
+    })
 
   let teardownUI: () => void
   let disposeFloat: () => void
@@ -291,33 +317,54 @@ export function setupInjectedUI (
     el.setAttribute('resizable', 'true')
     ui.close && (el.dataset.close = ui.close)
     el.classList.add('lsp-ui-float-container', 'visible')
-    disposeFloat = (
-      pl._setupResizableContainer(el, key),
-        pl._setupDraggableContainer(el, { key, close: () => teardownUI(), title: attrs?.title }))
+    disposeFloat =
+      (pl._setupResizableContainer(el, key),
+      pl._setupDraggableContainer(el, {
+        key,
+        close: () => teardownUI(),
+        title: attrs?.title,
+      }))
   }
 
   if (!!slot && ui.reset) {
-    const exists = Array.from(target.querySelectorAll('[data-injected-ui]'))
-      .map((it: HTMLElement) => it.id)
+    const exists = Array.from(
+      target.querySelectorAll('[data-injected-ui]')
+    ).map((it: HTMLElement) => it.id)
 
     exists?.forEach((exist: string) => {
       injectedUIEffects.get(exist)?.call(null)
     })
   }
 
-  target.appendChild(el);
+  target.appendChild(el)
 
   // TODO: How handle events
-  ['click', 'focus', 'focusin', 'focusout', 'blur', 'dblclick',
-    'keyup', 'keypress', 'keydown', 'change', 'input'].forEach((type) => {
-    el.addEventListener(type, (e) => {
-      const target = e.target! as HTMLElement
-      const trigger = target.closest(`[data-on-${type}]`) as HTMLElement
-      if (!trigger) return
-
-      const msgType = trigger.dataset[`on${ucFirst(type)}`]
-      msgType && pl.caller?.callUserModel(msgType, transformableEvent(trigger, e))
-    }, false)
+  ;[
+    'click',
+    'focus',
+    'focusin',
+    'focusout',
+    'blur',
+    'dblclick',
+    'keyup',
+    'keypress',
+    'keydown',
+    'change',
+    'input',
+  ].forEach((type) => {
+    el.addEventListener(
+      type,
+      (e) => {
+        const target = e.target! as HTMLElement
+        const trigger = target.closest(`[data-on-${type}]`) as HTMLElement
+        if (!trigger) return
+
+        const msgType = trigger.dataset[`on${ucFirst(type)}`]
+        msgType &&
+          pl.caller?.callUserModel(msgType, transformableEvent(trigger, e))
+      },
+      false
+    )
   })
 
   // callback
@@ -333,16 +380,20 @@ export function setupInjectedUI (
   return teardownUI
 }
 
-export function transformableEvent (target: HTMLElement, e: Event) {
+export function cleanInjectedScripts(this: PluginLocal) {
+  const scripts = document.head.querySelectorAll(`script[data-ref=${this.id}]`)
+
+  scripts?.forEach((it) => it.remove())
+}
+
+export function transformableEvent(target: HTMLElement, e: Event) {
   const obj: any = {}
 
   if (target) {
     const ds = target.dataset
     const FLAG_RECT = 'rect'
 
-    ;['value', 'id', 'className',
-      'dataset', FLAG_RECT
-    ].forEach((k) => {
+    ;['value', 'id', 'className', 'dataset', FLAG_RECT].forEach((k) => {
       let v: any
 
       switch (k) {
@@ -367,7 +418,7 @@ export function transformableEvent (target: HTMLElement, e: Event) {
 
 let injectedThemeEffect: any = null
 
-export function setupInjectedTheme (url?: string) {
+export function setupInjectedTheme(url?: string) {
   injectedThemeEffect?.call()
 
   if (!url) return
@@ -387,9 +438,10 @@ export function setupInjectedTheme (url?: string) {
   })
 }
 
-export function mergeSettingsWithSchema (
+export function mergeSettingsWithSchema(
   settings: Record<string, any>,
-  schema: Array<SettingSchemaDesc>) {
+  schema: Array<SettingSchemaDesc>
+) {
   const defaults = (schema || []).reduce((a, b) => {
     if ('default' in b) {
       a[b.key] = b.default
@@ -399,4 +451,4 @@ export function mergeSettingsWithSchema (
 
   // shadow copy
   return Object.assign(defaults, settings)
-}
+}

+ 67 - 0
libs/src/modules/LSPlugin.Experiments.ts

@@ -0,0 +1,67 @@
+import { LSPluginUser } from '../LSPlugin.user'
+import { PluginLocal } from '../LSPlugin.core'
+import { safeSnakeCase } from '../helpers'
+
+/**
+ * Some experiment features
+ */
+export class LSPluginExperiments {
+  constructor(private ctx: LSPluginUser) {}
+
+  get React(): unknown {
+    return this.ensureHostScope().React
+  }
+
+  get ReactDOM(): unknown {
+    return this.ensureHostScope().ReactDOM
+  }
+
+  get pluginLocal(): PluginLocal {
+    return this.ensureHostScope().LSPluginCore.ensurePlugin(
+      this.ctx.baseInfo.id
+    )
+  }
+
+  private invokeExperMethod(type: string, ...args: Array<any>) {
+    const host = this.ensureHostScope()
+    type = safeSnakeCase(type)?.toLowerCase()
+    return host.logseq.api['exper_' + type]?.apply(host, args)
+  }
+
+  async loadScripts(...scripts: Array<string>) {
+    scripts = scripts.map((it) => {
+      if (!it?.startsWith('http')) {
+        return this.ctx.resolveResourceFullUrl(it)
+      }
+
+      return it
+    })
+
+    scripts.unshift(this.ctx.baseInfo.id)
+    await this.invokeExperMethod('loadScripts', ...scripts)
+  }
+
+  registerFencedCodeRenderer(
+    type: string,
+    opts: {
+      edit?: boolean
+      before?: () => Promise<void>
+      subs?: Array<string>
+      render: (props: { content: string }) => any
+    }
+  ) {
+    return this.ensureHostScope().logseq.api.exper_register_fenced_code_renderer(
+      this.ctx.baseInfo.id,
+      type,
+      opts
+    )
+  }
+
+  ensureHostScope(): any {
+    if (window === top) {
+      throw new Error('Can not access host scope!')
+    }
+
+    return top
+  }
+}

+ 18 - 22
libs/src/modules/LSPlugin.Storage.ts

@@ -1,15 +1,15 @@
 import { LSPluginUser } from '../LSPlugin.user'
 
 export interface IAsyncStorage {
-  getItem (key: string): Promise<string | undefined>
+  getItem(key: string): Promise<string | undefined>
 
-  setItem (key: string, value: string): Promise<void>
+  setItem(key: string, value: string): Promise<void>
 
-  removeItem (key: string): Promise<void>
+  removeItem(key: string): Promise<void>
 
-  hasItem (key: string): Promise<boolean>
+  hasItem(key: string): Promise<boolean>
 
-  clear (): Promise<void>
+  clear(): Promise<void>
 }
 
 /**
@@ -19,14 +19,12 @@ class LSPluginFileStorage implements IAsyncStorage {
   /**
    * @param ctx
    */
-  constructor (
-    private ctx: LSPluginUser
-  ) {}
+  constructor(private ctx: LSPluginUser) {}
 
   /**
    * plugin id
    */
-  get ctxId () {
+  get ctxId() {
     return this.ctx.baseInfo.id
   }
 
@@ -34,54 +32,52 @@ class LSPluginFileStorage implements IAsyncStorage {
    * @param key A string as file name that support nested directory
    * @param value Storage value
    */
-  setItem (key: string, value: string): Promise<void> {
+  setItem(key: string, value: string): Promise<void> {
     return this.ctx.caller.callAsync(`api:call`, {
       method: 'write-plugin-storage-file',
-      args: [this.ctxId, key, value]
+      args: [this.ctxId, key, value],
     })
   }
 
   /**
    * @param key
    */
-  getItem (key: string): Promise<string | undefined> {
+  getItem(key: string): Promise<string | undefined> {
     return this.ctx.caller.callAsync(`api:call`, {
       method: 'read-plugin-storage-file',
-      args: [this.ctxId, key]
+      args: [this.ctxId, key],
     })
   }
 
   /**
    * @param key
    */
-  removeItem (key: string): Promise<void> {
+  removeItem(key: string): Promise<void> {
     return this.ctx.caller.call(`api:call`, {
       method: 'unlink-plugin-storage-file',
-      args: [this.ctxId, key]
+      args: [this.ctxId, key],
     })
   }
 
   /**
    * Clears the storage
    */
-  clear (): Promise<void> {
+  clear(): Promise<void> {
     return this.ctx.caller.call(`api:call`, {
       method: 'clear-plugin-storage-files',
-      args: [this.ctxId]
+      args: [this.ctxId],
     })
   }
 
   /**
    * @param key
    */
-  hasItem (key: string): Promise<boolean> {
+  hasItem(key: string): Promise<boolean> {
     return this.ctx.caller.callAsync(`api:call`, {
       method: 'exist-plugin-storage-file',
-      args: [this.ctxId, key]
+      args: [this.ctxId, key],
     })
   }
 }
 
-export {
-  LSPluginFileStorage
-}
+export { LSPluginFileStorage }

+ 84 - 61
libs/src/postmate/index.ts

@@ -27,7 +27,7 @@ export const generateNewMessageId = () => ++_messageId
 /**
  * Postmate logging function that enables/disables via config
  */
-export const log = (...args) => Postmate.debug ? console.log(...args) : null
+export const log = (...args) => (Postmate.debug ? console.log(...args) : null)
 
 /**
  * Takes a URL and returns the origin
@@ -38,7 +38,11 @@ export const resolveOrigin = (url) => {
   const a = document.createElement('a')
   a.href = url
   const protocol = a.protocol.length > 4 ? a.protocol : window.location.protocol
-  const host = a.host.length ? ((a.port === '80' || a.port === '443') ? a.hostname : a.host) : window.location.host
+  const host = a.host.length
+    ? a.port === '80' || a.port === '443'
+      ? a.hostname
+      : a.host
+    : window.location.host
   return a.origin || `${protocol}//${host}`
 }
 
@@ -58,15 +62,11 @@ const messageTypes = {
  * @return {Boolean}
  */
 export const sanitize = (message, allowedOrigin) => {
-  if (
-    typeof allowedOrigin === 'string' &&
-    message.origin !== allowedOrigin
-  ) return false
+  if (typeof allowedOrigin === 'string' && message.origin !== allowedOrigin)
+    return false
   if (!message.data) return false
-  if (
-    typeof message.data === 'object' &&
-    !('postmate' in message.data)
-  ) return false
+  if (typeof message.data === 'object' && !('postmate' in message.data))
+    return false
   if (message.data.type !== messageType) return false
   if (!messageTypes[message.data.postmate]) return false
   return true
@@ -80,8 +80,8 @@ export const sanitize = (message, allowedOrigin) => {
  * @return {Promise}
  */
 export const resolveValue = (model, property) => {
-  const unwrappedContext = typeof model[property] === 'function'
-    ? model[property]() : model[property]
+  const unwrappedContext =
+    typeof model[property] === 'function' ? model[property]() : model[property]
   return Promise.resolve(unwrappedContext)
 }
 
@@ -97,7 +97,7 @@ export class ParentAPI {
   public childOrigin: string
   public listener: (e: any) => void
 
-  constructor (info: Postmate) {
+  constructor(info: Postmate) {
     this.parent = info.parent
     this.frame = info.frame
     this.child = info.child
@@ -114,14 +114,14 @@ export class ParentAPI {
       /**
        * the assignments below ensures that e, data, and value are all defined
        */
-      const { data, name } = (((e || {}).data || {}).value || {})
+      const { data, name } = ((e || {}).data || {}).value || {}
 
       if (e.data.postmate === 'emit') {
         if (process.env.NODE_ENV !== 'production') {
           log(`Parent: Received event emission: ${name}`)
         }
         if (name in this.events) {
-          this.events[name].forEach(callback => {
+          this.events[name].forEach((callback) => {
             callback.call(this, data)
           })
         }
@@ -134,7 +134,7 @@ export class ParentAPI {
     }
   }
 
-  get (property) {
+  get(property) {
     return new Promise((resolve) => {
       // Extract data from response and kill listeners
       const uid = generateNewMessageId()
@@ -149,33 +149,39 @@ export class ParentAPI {
       this.parent.addEventListener('message', transact, false)
 
       // Then ask child for information
-      this.child.postMessage({
-        postmate: 'request',
-        type: messageType,
-        property,
-        uid,
-      }, this.childOrigin)
+      this.child.postMessage(
+        {
+          postmate: 'request',
+          type: messageType,
+          property,
+          uid,
+        },
+        this.childOrigin
+      )
     })
   }
 
-  call (property, data) {
+  call(property, data) {
     // Send information to the child
-    this.child.postMessage({
-      postmate: 'call',
-      type: messageType,
-      property,
-      data,
-    }, this.childOrigin)
+    this.child.postMessage(
+      {
+        postmate: 'call',
+        type: messageType,
+        property,
+        data,
+      },
+      this.childOrigin
+    )
   }
 
-  on (eventName, callback) {
+  on(eventName, callback) {
     if (!this.events[eventName]) {
       this.events[eventName] = []
     }
     this.events[eventName].push(callback)
   }
 
-  destroy () {
+  destroy() {
     if (process.env.NODE_ENV !== 'production') {
       log('Parent: Destroying Postmate instance')
     }
@@ -194,7 +200,7 @@ export class ChildAPI {
   private parentOrigin: string
   private child: Window
 
-  constructor (info: Model) {
+  constructor(info: Model) {
     this.model = info.model
     this.parent = info.parent
     this.parentOrigin = info.parentOrigin
@@ -215,38 +221,46 @@ export class ChildAPI {
       const { property, uid, data } = e.data
 
       if (e.data.postmate === 'call') {
-        if (property in this.model && typeof this.model[property] === 'function') {
+        if (
+          property in this.model &&
+          typeof this.model[property] === 'function'
+        ) {
           this.model[property](data)
         }
         return
       }
 
       // Reply to Parent
-      resolveValue(this.model, property)
-        .then(value => {
-          (e.source as WindowProxy).postMessage({
+      resolveValue(this.model, property).then((value) => {
+        ;(e.source as WindowProxy).postMessage(
+          {
             property,
             postmate: 'reply',
             type: messageType,
             uid,
             value,
-          }, e.origin)
-        })
+          },
+          e.origin
+        )
+      })
     })
   }
 
-  emit (name, data) {
+  emit(name, data) {
     if (process.env.NODE_ENV !== 'production') {
       log(`Child: Emitting Event "${name}"`, data)
     }
-    this.parent.postMessage({
-      postmate: 'emit',
-      type: messageType,
-      value: {
-        name,
-        data,
+    this.parent.postMessage(
+      {
+        postmate: 'emit',
+        type: messageType,
+        value: {
+          name,
+          data,
+        },
       },
-    }, this.parentOrigin)
+      this.parentOrigin
+    )
   }
 }
 
@@ -276,14 +290,17 @@ export class Postmate {
   /**
    * @param opts
    */
-  constructor (opts: PostMateOptions) {
+  constructor(opts: PostMateOptions) {
     this.container = opts.container
     this.url = opts.url
     this.parent = window
     this.frame = document.createElement('iframe')
     if (opts.id) this.frame.id = opts.id
     if (opts.name) this.frame.name = opts.name
-    this.frame.classList.add.apply(this.frame.classList, opts.classListArray || [])
+    this.frame.classList.add.apply(
+      this.frame.classList,
+      opts.classListArray || []
+    )
     this.container.appendChild(this.frame)
     this.child = this.frame.contentWindow
     this.model = opts.model || {}
@@ -294,7 +311,7 @@ export class Postmate {
    * @param  {String} url The URL to send a handshake request to
    * @return {Promise}     Promise that resolves when the handshake is complete
    */
-  sendHandshake (url?: string) {
+  sendHandshake(url?: string) {
     url = url || this.url
     const childOrigin = resolveOrigin(url)
     let attempt = 0
@@ -330,11 +347,14 @@ export class Postmate {
         if (process.env.NODE_ENV !== 'production') {
           log(`Parent: Sending handshake attempt ${attempt}`, { childOrigin })
         }
-        this.child.postMessage({
-          postmate: 'handshake',
-          type: messageType,
-          model: this.model,
-        }, childOrigin)
+        this.child.postMessage(
+          {
+            postmate: 'handshake',
+            type: messageType,
+            model: this.model,
+          },
+          childOrigin
+        )
 
         if (attempt === maxHandshakeRequests) {
           clearInterval(responseInterval)
@@ -370,7 +390,7 @@ export class Model {
    * @param {Object} model Hash of values, functions, or promises
    * @return {Promise}       The Promise that resolves when the handshake has been received
    */
-  constructor (model) {
+  constructor(model) {
     this.child = window
     this.model = model
     this.parent = this.child.parent
@@ -380,7 +400,7 @@ export class Model {
    * Responds to a handshake initiated by the Parent
    * @return {Promise} Resolves an object that exposes an API for the Child
    */
-  sendHandshakeReply () {
+  sendHandshakeReply() {
     return new Promise((resolve, reject) => {
       const shake = (e: MessageEvent<any>) => {
         if (!e.data.postmate) {
@@ -394,16 +414,19 @@ export class Model {
           if (process.env.NODE_ENV !== 'production') {
             log('Child: Sending handshake reply to Parent')
           }
-          (e.source as WindowProxy).postMessage({
-            postmate: 'handshake-reply',
-            type: messageType,
-          }, e.origin)
+          ;(e.source as WindowProxy).postMessage(
+            {
+              postmate: 'handshake-reply',
+              type: messageType,
+            },
+            e.origin
+          )
           this.parentOrigin = e.origin
 
           // Extend model with the one provided by the parent
           const defaults = e.data.model
           if (defaults) {
-            Object.keys(defaults).forEach(key => {
+            Object.keys(defaults).forEach((key) => {
               this.model[key] = defaults[key]
             })
             if (process.env.NODE_ENV !== 'production') {

+ 4 - 0
libs/webpack.config.js

@@ -1,3 +1,4 @@
+const pkg = require('./package.json')
 const path = require('path')
 const webpack = require('webpack')
 const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
@@ -20,6 +21,9 @@ module.exports = {
     new webpack.ProvidePlugin({
       process: 'process/browser',
     }),
+    new webpack.DefinePlugin({
+      LIB_VERSION: JSON.stringify(pkg.version)
+    })
     // new BundleAnalyzerPlugin()
   ],
   output: {

+ 10 - 0
libs/yarn.lock

@@ -857,6 +857,16 @@ pkg-dir@^4.2.0:
   dependencies:
     find-up "^4.0.0"
 
+prettier-config-standard@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/prettier-config-standard/-/prettier-config-standard-5.0.0.tgz#c99dbef099412eda0876f75fdc1732ffef2ab0e0"
+  integrity sha512-QK252QwCxlsak8Zx+rPKZU31UdbRcu9iUk9X1ONYtLSO221OgvV9TlKoTf6iPDZtvF3vE2mkgzFIEgSUcGELSQ==
+
+prettier@^2.6.2:
+  version "2.6.2"
+  resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.6.2.tgz#e26d71a18a74c3d0f0597f55f01fb6c06c206032"
+  integrity sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew==
+
 process-nextick-args@~2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"

文件差异内容过多而无法显示
+ 0 - 1
resources/js/lsplugin.core.js


+ 7 - 0
src/electron/electron/git.cljs

@@ -33,6 +33,13 @@
               (log-error error))
             (p/rejected error)))))))
 
+(defn run-git2!
+  [commands]
+  (when-let [path (state/get-graph-path)]
+    (when (fs/existsSync path)
+      (p/let [^js result (.exec GitProcess commands path)]
+        result))))
+
 (defn git-dir-exists?
   []
   (try

+ 11 - 0
src/electron/electron/handler.cljs

@@ -7,6 +7,7 @@
             ["path" :as path]
             ["os" :as os]
             ["diff-match-patch" :as google-diff]
+            ["/electron/utils" :as js-utils]
             [electron.fs-watcher :as watcher]
             [electron.configs :as cfgs]
             [promesa.core :as p]
@@ -336,6 +337,11 @@
 (defmethod handle :getAppBaseInfo [^js win [_ _opts]]
   {:isFullScreen (.isFullScreen win)})
 
+(defmethod handle :getAssetsFiles [^js win [_ {:keys [exts]}]]
+  (when-let [graph-path (state/get-window-graph-path win)]
+    (p/let [^js files (js-utils/getAllFiles (.join path graph-path "assets") (clj->js exts))]
+           files)))
+
 (defn close-watcher-when-orphaned!
   "When it's the last window for the directory, close the watcher."
   [window graph-path]
@@ -358,6 +364,11 @@
   (when (seq args)
     (git/raw! args)))
 
+(defmethod handle :runGitWithinCurrentGraph [_ [_ args]]
+  (when (seq args)
+    (git/init!)
+    (git/run-git2! (clj->js args))))
+
 (defmethod handle :gitCommitAll [_ [_ message]]
   (git/add-all-and-commit! message))
 

+ 9 - 6
src/electron/electron/plugin.cljs

@@ -29,7 +29,8 @@
 (defn fetch-latest-release-asset
   [{:keys [repo theme]}]
   (p/catch
-    (p/let [api #(str "https://api.github.com/repos/" repo "/" %)
+    (p/let [repo (some-> repo (string/trim) (string/replace #"^/+(.+?)/+$" "$1"))
+            api #(str "https://api.github.com/repos/" repo "/" %)
             endpoint (api "releases/latest")
             ^js res (fetch endpoint)
             res (.json res)
@@ -47,14 +48,15 @@
        (:body res)])
 
     (fn [^js e]
-      (emit :lsp-installed {:status :error :payload e})
-      (throw (js/Error. :release-network-issue)))))
+      (debug e)
+      (throw (js/Error. [:release-channel-issue (.-message e)])))))
 
 (defn download-asset-zip
   [{:keys [id repo title author description effect sponsors]} dl-url dl-version dot-extract-to]
   (p/catch
     (p/let [^js res (fetch dl-url {:timeout 30000})
-            _ (when-not (.-ok res) (throw (js/Error. :download-network-issue)))
+            _ (when-not (.-ok res)
+                (throw (js/Error. [:download-channel-issue (.-statusText res)])))
             frm-zip (p/create
                       (fn [resolve1 reject1]
                         (let [body (.-body res)
@@ -155,7 +157,7 @@
 
                           _ (when-not dl-url
                               (debug "[Download URL Error]" asset)
-                              (throw (js/Error. :release-asset-not-found)))
+                              (throw (js/Error. [:release-asset-not-found (js/JSON.stringify asset)])))
 
                           dest (.join path cfgs/dot-root "plugins" (:id item))
                           _ (when-not only-check (download-asset-zip item dl-url latest-version dest))
@@ -175,7 +177,8 @@
                       (emit :lsp-installed
                             {:status     :error
                              :only-check only-check
-                             :payload    (assoc item :error-code (.-message e))}))
+                             :payload    (assoc item :error-code (.-message e))})
+                      (debug e))
                     (resolve nil)))))
 
           (p/finally

+ 51 - 13
src/electron/electron/utils.js

@@ -1,18 +1,56 @@
+const path = require('path')
+const { readdir, lstat } = require('fs').promises
+
 // workaround from https://github.com/electron/electron/issues/426#issuecomment-658901422
 // We set an intercept on incoming requests to disable x-frame-options
 // headers.
 
 export const disableXFrameOptions = (win) => {
-  win.webContents.session.webRequest.onHeadersReceived({ urls: [ "*://*/*" ] },
-                                                       (d, c)=>{
-                                                         if(d.responseHeaders['X-Frame-Options']){
-                                                           delete d.responseHeaders['X-Frame-Options'];
-                                                         } else if(d.responseHeaders['x-frame-options']) {
-                                                           delete d.responseHeaders['x-frame-options'];
-                                                         }
-
-                                                         c({cancel: false, responseHeaders: d.responseHeaders});
-                                                       }
-                                                      );
-
-};
+  win.webContents.session.webRequest.onHeadersReceived({ urls: ['*://*/*'] },
+    (d, c) => {
+      if (d.responseHeaders['X-Frame-Options']) {
+        delete d.responseHeaders['X-Frame-Options']
+      } else if (d.responseHeaders['x-frame-options']) {
+        delete d.responseHeaders['x-frame-options']
+      }
+
+      c({ cancel: false, responseHeaders: d.responseHeaders })
+    }
+  )
+}
+
+export async function getAllFiles (dir, exts) {
+  const dirents = await readdir(dir, { withFileTypes: true })
+
+  if (exts) {
+    !Array.isArray(exts) && (exts = [exts])
+
+    exts = exts.map(it => {
+      if (it && !it.startsWith('.')) {
+        it = '.' + it
+      }
+
+      return it?.toLowerCase()
+    })
+  }
+
+  const files = await Promise.all(dirents.map(async (dirent) => {
+    if (exts && !exts.includes(path.extname(dirent.name))) {
+      return null
+    }
+
+    const filePath = path.resolve(dir, dirent.name)
+    const fileStats = await lstat(filePath)
+    const stats = {
+      size: fileStats.size,
+      accessTime: fileStats.atimeMs,
+      modifiedTime: fileStats.mtimeMs,
+      changeTime: fileStats.ctimeMs,
+      birthTime: fileStats.birthtimeMs
+    }
+    return dirent.isDirectory() ? getAllFiles(filePath) : {
+      path: filePath, ...stats
+    }
+  }))
+  return files.flat().filter(it => it != null)
+}

+ 4 - 1
src/main/frontend/components/block.cljs

@@ -2880,7 +2880,10 @@
               [:sup.fn (str name "↩︎")]])]])
 
         ["Src" options]
-        (src-cp config options html-export?)
+        [:div.cp__fenced-code-block
+         (if-let [opts (plugin-handler/hook-fenced-code-by-type (util/safe-lower-case (:language options)))]
+           (plugins/hook-ui-fenced-code (string/join "" (:lines options)) opts)
+           (src-cp config options html-export?))]
 
         :else
         "")

+ 56 - 35
src/main/frontend/components/block.css

@@ -1,9 +1,11 @@
 .block-content-wrapper {
   /* 38px is the width of block-control */
-    width: calc(100% - 22px);
-    @screen sm {
-        width: calc(100% - 33px);
-    }
+  width: calc(100% - 22px);
+
+  @screen sm {
+    width: calc(100% - 33px);
+    overflow-x: auto;
+  }
 }
 
 .block-content {
@@ -92,7 +94,7 @@
   }
 
   &-fallback-ui {
-    padding:10px 15px;
+    padding: 10px 15px;
     border-color: var(--ls-secondary-border-color);
   }
 }
@@ -181,7 +183,15 @@
   .control-hide {
     display: none;
   }
- }
+}
+
+html.is-mobile,
+html.is-native-iphone,
+html.is-native-android {
+  .references .block-control {
+    margin-left: -20px;
+  }
+}
 
 .block-ref {
   border-bottom: 0.5px solid;
@@ -200,19 +210,19 @@
 }
 
 .block-ref-no-title {
-    border-bottom: 0.5px solid;
-    border-bottom-color: var(--ls-block-ref-link-text-color);
-    cursor: alias;
-    padding: 2px 5px;
-    display: block;
+  border-bottom: 0.5px solid;
+  border-bottom-color: var(--ls-block-ref-link-text-color);
+  cursor: alias;
+  padding: 2px 5px;
+  display: block;
 
-    &:hover {
-        color: var(--ls-link-text-hover-color);
-    }
+  &:hover {
+    color: var(--ls-link-text-hover-color);
+  }
 
-    .block-content {
-        cursor: inherit;
-    }
+  .block-content {
+    cursor: inherit;
+  }
 }
 
 .page-ref {
@@ -234,7 +244,7 @@
 
 .embed-page {
   @apply py-2 my-2 px-2;
-  
+
   > section {
     margin-bottom: 5px;
   }
@@ -349,41 +359,46 @@
 }
 
 .block-ref :is(h1, h2, h3, h4, h5, h6) {
-    border-bottom: none;
-    font-size: 1rem;
+  border-bottom: none;
+  font-size: 1rem;
 }
 
 .document-mode .ls-block h1,
 .document-mode .editor-inner .h1 {
   margin: 0.67em 0;
 }
+
 .document-mode .ls-block h2,
 .document-mode .editor-inner .h2 {
   margin: 0.75em 0;
 }
+
 .document-mode .ls-block h3,
 .document-mode .editor-inner .h3 {
   margin: 0.83em 0;
 }
+
 .document-mode .ls-block h4,
 .document-mode .editor-inner .h4 {
   margin: 1.12em 0;
 }
+
 .document-mode .ls-block h5,
 .document-mode .editor-inner .h5 {
   margin: 1.5em 0;
 }
+
 .document-mode .ls-block h6,
 .document-mode .editor-inner .h6 {
   margin: 1.67em 0;
 }
 
 .document-mode .block-children {
-    border-left: 0px solid;
+  border-left: 0px solid;
 }
 
 .document-mode .ls-block {
-    margin-bottom: 1rem;
+  margin-bottom: 1rem;
 }
 
 .color-level {
@@ -460,7 +475,7 @@ a:hover > .bullet-container .bullet {
 }
 
 a:hover > .bullet-container {
-    background-color: var(--ls-block-bullet-border-color, #ced9e0);
+  background-color: var(--ls-block-bullet-border-color, #ced9e0);
 }
 
 .doc-mode {
@@ -511,39 +526,39 @@ a.filter svg {
 }
 
 .table-query-properties svg {
-    display: inline;
+  display: inline;
 }
 
 .query-title {
-    background: var(--ls-page-properties-background-color);
+  background: var(--ls-page-properties-background-color);
 }
 
 .ls-card {
-    height: 100%;
+  height: 100%;
 }
 
 @media (min-width: 1024px) {
-    .ui__modal-panel .ls-card {
-        min-height: 24rem;
-    }
+  .ui__modal-panel .ls-card {
+    min-height: 24rem;
+  }
 }
 
 a[data-ref="card"], .page-reference[data-ref="card"] {
-    opacity: 0.3;
+  opacity: 0.3;
 }
 
 .ls-card a[data-ref="card"], .ls-card .page-reference[data-ref="card"] {
-    display: none;
+  display: none;
 }
 
 a.cloze {
-    color: var(--ls-cloze-text-color);
+  color: var(--ls-cloze-text-color);
 }
 
 a.cloze-revealed {
-    color: var(--ls-cloze-text-color);
-    text-decoration: underline;
-    text-underline-position: under;
+  color: var(--ls-cloze-text-color);
+  text-decoration: underline;
+  text-underline-position: under;
 }
 
 .page-property-key {
@@ -558,6 +573,12 @@ a.cloze-revealed {
   opacity: 1;
 }
 
+.cp__fenced-code-block {
+  .not-edit {
+    cursor: default;
+  }
+}
+
 html.is-native-ios {
     audio {
         width: 300px;

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

@@ -40,7 +40,7 @@
   [page]
   (let [namespaces (get-relation page)]
     (when (seq namespaces)
-      [:div.page-hierachy.mt-6
+      [:div.page-hierarchy.mt-6
        (ui/foldable
         [:h2.font-bold.opacity-30 "Hierarchy"]
         [:ul.namespaces {:style {:margin "12px 24px"}}

+ 22 - 17
src/main/frontend/components/plugins.cljs

@@ -13,7 +13,6 @@
             [frontend.components.plugins-settings :as plugins-settings]
             [frontend.handler.notification :as notification]
             [frontend.handler.plugin :as plugin-handler]
-            [frontend.handler.page :as page-handler]
             [clojure.string :as string]))
 
 (rum/defcs installed-themes
@@ -234,8 +233,7 @@
 
     (ui/toggle (not disabled?)
                (fn []
-                 (js-invoke js/LSPluginCore (if disabled? "enable" "disable") id)
-                 (page-handler/init-commands!))
+                 (js-invoke js/LSPluginCore (if disabled? "enable" "disable") id))
                true)]])
 
 (rum/defc plugin-item-card < rum/static
@@ -316,10 +314,12 @@
    [:input.form-input.is-small
     {:placeholder "Search plugins"
      :ref         *search-ref
+     :auto-focus  true
      :on-key-down (fn [^js e]
                     (when (= 27 (.-keyCode e))
-                      (when-not (string/blank? search-key)
-                        (util/stop e)
+                      (util/stop e)
+                      (if (string/blank? search-key)
+                        (some-> (js/document.querySelector ".cp__plugins-page") (.focus))
                         (reset! *search-key nil))))
      :on-change   #(let [^js target (.-target %)]
                      (reset! *search-key (util/trim-safe (.-value target))))
@@ -726,10 +726,7 @@
       [:span])))
 
 (rum/defcs hook-ui-items < rum/reactive
-                           "type
-                                                        - :toolbar
-                                                        - :pagebar
-                                                     "
+ "type: :toolbar, :pagebar"
   [_state type]
   (when (state/sub [:plugin/installed-ui-items])
     (let [items (state/get-plugins-ui-items-with-type type)]
@@ -739,6 +736,15 @@
          (for [[_ {:keys [key] :as opts} pid] items]
            (rum/with-key (ui-item-renderer pid type opts) key))]))))
 
+(rum/defcs hook-ui-fenced-code < rum/reactive
+  [_state content {:keys [render edit] :as _opts}]
+
+  [:div
+   {:on-mouse-down (fn [e] (when (false? edit) (util/stop e)))
+    :class         (util/classnames [{:not-edit (false? edit)}])}
+   (when (fn? render)
+     (js/React.createElement render #js {:content content}))])
+
 (rum/defc plugins-page
   []
 
@@ -746,11 +752,6 @@
         market? (= active :marketplace)
         *el-ref (rum/create-ref)]
 
-    (rum/use-effect!
-      #(let [^js el (rum/deref *el-ref)]
-         (js/setTimeout (fn [] (.focus el)) 100))
-      [])
-
     [:div.cp__plugins-page
      {:ref       *el-ref
       :tab-index "-1"}
@@ -775,10 +776,13 @@
 
 (rum/defcs focused-settings-content
   < rum/reactive
+    (rum/local (state/sub :plugin/focused-settings) ::cache)
   [_state title]
-  (let [focused (state/sub :plugin/focused-settings)
+  (let [*cache (::cache _state)
+        focused (state/sub :plugin/focused-settings)
         nav? (state/sub :plugin/navs-settings?)
-        _ (state/sub :plugin/installed-plugins)]
+        _ (state/sub :plugin/installed-plugins)
+        _ (js/setTimeout #(reset! *cache focused) 100)]
 
     [:div.cp__plugins-settings.cp__settings-main
      [:header
@@ -802,7 +806,8 @@
 
       [:article
        [:div.panel-wrap
-        (when-let [^js pl (and focused (plugin-handler/get-plugin-inst focused))]
+        (when-let [^js pl (and focused (= @*cache focused)
+                               (plugin-handler/get-plugin-inst focused))]
           (ui/catch-error
             [:p.warning.text-lg.mt-5 "Settings schema Error!"]
             (plugins-settings/settings-container

+ 0 - 1
src/main/frontend/components/plugins.css

@@ -720,7 +720,6 @@
 
       &.is-dragging {
         /*height: var(--ls-draggable-handle-height) !important;*/
-        overflow: hidden;
         opacity: .7;
 
         > .draggable-handle {

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

@@ -3,7 +3,8 @@
             [frontend.util :as util]
             [frontend.ui :as ui]
             [frontend.handler.plugin :as plugin-handler]
-            [cljs-bean.core :as bean]))
+            [cljs-bean.core :as bean]
+            [goog.functions :refer [debounce]]))
 
 (rum/defc edit-settings-file
   [pid {:keys [class]}]
@@ -24,10 +25,10 @@
     (let [input-as (util/safe-lower-case (or inputAs (name type)))
           input-as (if (= input-as "string") :text (keyword input-as))]
       [:input
-       {:class     (util/classnames [{:form-input (not (contains? #{:color :range} input-as))}])
-        :type      (name input-as)
-        :value     (or val default)
-        :on-change #(update-setting! key (util/evalue %))}])]])
+       {:class        (util/classnames [{:form-input (not (contains? #{:color :range} input-as))}])
+        :type         (name input-as)
+        :defaultValue (or val default)
+        :on-change    (debounce #(update-setting! key (util/evalue %)) 1000)}])]])
 
 (rum/defc render-item-toggle
   [val {:keys [key title description default]} update-setting!]
@@ -77,12 +78,11 @@
   [schema ^js pl]
   (let [^js _settings (.-settings pl)
         pid (.-id pl)
-        [settings, set-settings] (rum/use-state nil)
+        [settings, set-settings] (rum/use-state (bean/->clj (.toJSON _settings)))
         update-setting! (fn [k v] (.set _settings (name k) (bean/->js v)))]
 
     (rum/use-effect!
       (fn []
-        (set-settings (bean/->clj (.toJSON _settings)))
         (let [on-change (fn [^js s]
                           (when-let [s (bean/->clj s)]
                             (set-settings s)))]

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

@@ -69,9 +69,15 @@
       flex: 1;
       padding: 0 12px 12px;
       max-height: 70vh;
+      min-height: 380px;
+      width: auto;
       overflow: auto;
       margin-right: -17px;
       margin-bottom: -17px;
+
+      @screen md {
+        width: 680px;
+      }
     }
 
     &.no-aside {

+ 16 - 3
src/main/frontend/handler/events.cljs

@@ -325,9 +325,12 @@
 
     (when-let [coming (and (not downloading?)
                            (get-in @state/state [:plugin/updates-coming id]))]
-      (notification/show!
-       (str "Checked: " (:title coming))
-       :success))
+      (let [error-code (:error-code coming)
+            error-code (if (= error-code (str :no-new-version)) nil error-code)]
+        (when (or pending? (not error-code))
+          (notification/show!
+            (str "[Checked]<" (:title coming) "> " error-code)
+            (if error-code :error :success)))))
 
     (if (and updated? downloading?)
       ;; try to start consume downloading item
@@ -348,6 +351,13 @@
         (when (and pending? (seq (state/all-available-coming-updates)))
           (plugin/open-waiting-updates-modal!))))))
 
+(defmethod handle :plugin/hook-db-tx [[_ {:keys [blocks tx-data tx-meta] :as payload}]]
+  (when-let [payload (and (seq blocks)
+                          (merge payload {:tx-data (map #(into [] %) tx-data)
+                                          :tx-meta (dissoc tx-meta :editor-cursor)}))]
+    (plugin-handler/hook-plugin-db :changed payload)
+    (plugin-handler/hook-plugin-block-changes payload)))
+
 (defmethod handle :backup/broken-config [[_ repo content]]
   (when (and repo content)
     (let [path (config/get-config-path)
@@ -367,6 +377,9 @@
            :path
            js/decodeURI)))
 
+(defmethod handle :rebuild-slash-commands-list [[_]]
+  (page-handler/rebuild-slash-commands-list!))
+
 (defn run!
   []
   (let [chan (state/get-events-chan)]

+ 5 - 3
src/main/frontend/handler/notification.cljs

@@ -9,10 +9,12 @@
 
 (defn show!
   ([content status]
-   (show! content status true nil))
+   (show! content status true nil 1500))
   ([content status clear?]
-   (show! content status clear? nil))
+   (show! content status clear? nil 1500))
   ([content status clear? uid]
+   (show! content status clear? uid 1500))
+  ([content status clear? uid timeout]
    (let [contents (state/get-notification-contents)
          uid (or uid (keyword (util/unique-id)))]
      (state/set-state! :notification/contents (assoc contents
@@ -20,6 +22,6 @@
                                                           :status status}))
 
      (when (and clear? (not= status :error))
-       (js/setTimeout #(clear! uid) 1500))
+       (js/setTimeout #(clear! uid) (or timeout 1500)))
 
      uid)))

+ 5 - 1
src/main/frontend/handler/page.cljs

@@ -34,7 +34,8 @@
             [goog.object :as gobj]
             [lambdaisland.glogi :as log]
             [promesa.core :as p]
-            [frontend.mobile.util :as mobile-util]))
+            [frontend.mobile.util :as mobile-util]
+            [goog.functions :refer [debounce]]))
 
 (defn- get-directory
   [journal?]
@@ -598,6 +599,9 @@
   []
   (commands/init-commands! get-page-ref-text))
 
+(def rebuild-slash-commands-list!
+  (debounce init-commands! 1500))
+
 (defn template-exists?
   [title]
   (when title

+ 82 - 18
src/main/frontend/handler/plugin.cljs

@@ -2,6 +2,7 @@
   (:require [promesa.core :as p]
             [rum.core :as rum]
             [frontend.util :as util]
+            [clojure.walk :as walk]
             [frontend.format.mldoc :as mldoc]
             [frontend.handler.notification :as notifications]
             [camel-snake-kebab.core :as csk]
@@ -19,6 +20,16 @@
          (and (util/electron?)
               (state/lsp-enabled?-or-theme)))
 
+(defn- normalize-keyword-for-json
+  [input]
+  (when input
+    (walk/postwalk
+      (fn [a]
+        (cond
+          (keyword? a) (csk/->camelCase (name a))
+          (uuid? a) (str a)
+          :else a)) input)))
+
 (defn invoke-exported-api
   [type & args]
   (try
@@ -84,7 +95,7 @@
     (p/create
       (fn [resolve]
         (state/set-state! :plugin/installing mft)
-        (ipc/ipc "installMarketPlugin" mft)
+        (ipc/ipc :installMarketPlugin mft)
         (resolve id)))))
 
 (defn check-or-update-marketplace-plugin
@@ -100,20 +111,21 @@
                 (state/reset-all-updates-state)
                 (throw e))))
         (fn [mfts]
-          (if-let [mft (some #(when (= (:id %) id) %) mfts)]
-            (ipc/ipc "updateMarketPlugin" (merge (dissoc pkg :logger) mft))
-            (throw (js/Error. (str ":not-found-in-marketplace" id))))
+
+          (let [mft (some #(when (= (:id %) id) %) mfts)]
+            ;;TODO: (throw (js/Error. [:not-found-in-marketplace id]))
+            (ipc/ipc :updateMarketPlugin (merge (dissoc pkg :logger) mft)))
           true))
 
       (fn [^js e]
-        (error-handler "Update Error: remote error")
+        (error-handler e)
         (state/set-state! :plugin/installing nil)
         (js/console.error e)))))
 
 (defn get-plugin-inst
   [id]
   (try
-    (js/LSPluginCore.ensurePlugin id)
+    (js/LSPluginCore.ensurePlugin (name id))
     (catch js/Error _e
       nil)))
 
@@ -175,7 +187,7 @@
                                  (str (t :plugin/installed) (t :plugins) ": " name) :success)))))
 
                        :error
-                       (let [error-code (keyword (string/replace (:error-code payload) #"^[\s\:]+" ""))
+                       (let [error-code (keyword (string/replace (:error-code payload) #"^[\s\:\[]+" ""))
                              [msg type] (case error-code
 
                                           :no-new-version
@@ -195,7 +207,8 @@
                              ;; notify human tips
                              (notifications/show!
                                (str
-                                 (if (= :error type) "[Install Error]" "")
+                                 (if (= :error type) "[Error]" "")
+                                 (str "<" (:id payload) "> ")
                                  msg) type)))
 
                          (js/console.error payload))
@@ -228,13 +241,15 @@
   [pid [cmd actions]]
   (when-let [pid (keyword pid)]
     (when (contains? (:plugin/installed-plugins @state/state) pid)
-      (swap! state/state update-in [:plugin/installed-commands pid]
+      (swap! state/state update-in [:plugin/installed-slash-commands pid]
              (fnil merge {}) (hash-map cmd (mapv #(conj % {:pid pid}) actions)))
+      (state/pub-event! [:rebuild-slash-commands-list])
       true)))
 
 (defn unregister-plugin-slash-command
   [pid]
-  (swap! state/state medley/dissoc-in [:plugin/installed-commands (keyword pid)]))
+  (swap! state/state medley/dissoc-in [:plugin/installed-slash-commands (keyword pid)])
+  (state/pub-event! [:rebuild-slash-commands-list]))
 
 (def keybinding-mode-handler-map
   {:global      :shortcut.handler/editor-global
@@ -292,11 +307,43 @@
   [pid]
   (swap! state/state assoc-in [:plugin/installed-ui-items (keyword pid)] []))
 
+(defn register-plugin-resources
+  [pid type {:keys [key] :as opts}]
+  (when-let [pid (keyword pid)]
+    (when-let [type (and key (keyword type))]
+      (let [path [:plugin/installed-resources pid type]]
+        (when (contains? #{:error nil} (get-in @state/state (conj path key)))
+          (swap! state/state update-in path
+            (fnil assoc {}) key (merge opts {:pid pid}))
+          true)))))
+
+(defn unregister-plugin-resources
+  [pid]
+  (when-let [pid (keyword pid)]
+    (swap! state/state medley/dissoc-in [:plugin/installed-resources pid])
+    true))
+
 (defn unregister-plugin-themes
   ([pid] (unregister-plugin-themes pid true))
   ([pid effect]
    (js/LSPluginCore.unregisterTheme (name pid) effect)))
 
+(def *fenced-code-providers (atom #{}))
+
+(defn register_fenced_code_renderer
+  [pid type {:keys [before subs render edit] :as _opts}]
+  (when-let [key (and type (keyword type))]
+    (register-plugin-resources pid :fenced-code-renderers
+      {:key key :edit edit :before before :subs subs :render render})
+    (swap! *fenced-code-providers conj pid)
+    #(swap! *fenced-code-providers disj pid)))
+
+(defn hook-fenced-code-by-type
+  [type]
+  (when-let [key (and (seq @*fenced-code-providers) type (keyword type))]
+    (first (map #(state/get-plugin-resource % :fenced-code-renderers key)
+                @*fenced-code-providers))))
+
 (defn select-a-plugin-theme
   [pid]
   (when-let [themes (get (group-by :pid (:plugin/installed-themes @state/state)) pid)]
@@ -374,13 +421,16 @@
 (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))))
+    (try
+      (js-invoke js/LSPluginCore
+                 (str "hook" (string/capitalize (name tag)))
+                 (name type)
+                 (if (coll? payload)
+                   (bean/->js (normalize-keyword-for-json payload))
+                   payload)
+                 (if (keyword? plugin-id) (name plugin-id) plugin-id))
+      (catch js/Error e
+        (js/console.error "[Hook Plugin Err]" e)))))
 
 (defn hook-plugin-app
   ([type payload] (hook-plugin-app type payload nil))
@@ -390,6 +440,18 @@
   ([type payload] (hook-plugin-editor type payload nil))
   ([type payload plugin-id] (hook-plugin :editor type payload plugin-id)))
 
+(defn hook-plugin-db
+  ([type payload] (hook-plugin-db type payload nil))
+  ([type payload plugin-id] (hook-plugin :db type payload plugin-id)))
+
+(defn hook-plugin-block-changes
+  [{:keys [blocks tx-data tx-meta]}]
+
+  (doseq [b blocks
+          :let [tx-data' (group-by first tx-data)
+                type     (str "block:" (:block/uuid b))]]
+    (hook-plugin-db type {:block b :tx-data (get tx-data' (:db/id b)) :tx-meta tx-meta})))
+
 (defn get-ls-dotdir-root
   []
   (ipc/ipc "getLogseqDotDirRoot"))
@@ -480,7 +542,9 @@
                               ;; commands
                               (unregister-plugin-slash-command pid)
                               (invoke-exported-api "unregister_plugin_simple_command" pid)
-                              (unregister-plugin-ui-items pid))
+                              (invoke-exported-api "uninstall_plugin_hook" pid)
+                              (unregister-plugin-ui-items pid)
+                              (unregister-plugin-resources pid))
 
             _ (doto js/LSPluginCore
                 (.on "registered"

+ 4 - 0
src/main/frontend/handler/shell.cljs

@@ -12,6 +12,10 @@
   [command]
   (ipc/ipc "runGit" command))
 
+(defn run-git-command2!
+  [command]
+  (ipc/ipc "runGitWithinCurrentGraph" command))
+
 ;; TODO: export to pdf/html/word
 (defn run-pandoc-command!
   [command]

+ 9 - 4
src/main/frontend/loader.cljs

@@ -1,7 +1,12 @@
 (ns frontend.loader
   (:require [goog.net.jsloader :as jsloader]
-            [goog.html.legacyconversions :as conv]))
+            [goog.html.legacyconversions :as conv]
+            [cljs-bean.core :as bean]))
 
-(defn load [url ok-handler]
-  (let [loader (jsloader/safeLoad (conv/trustedResourceUrlFromString (str url)))]
-    (.addCallback ^goog.net.jsloader loader ok-handler)))
+(defn load
+  ([url ok-handler] (load url ok-handler nil))
+  ([url ok-handler opts]
+   (let [loader (jsloader/safeLoad
+                 (conv/trustedResourceUrlFromString (str url))
+                 (bean/->js opts))]
+     (.addCallback ^goog.net.jsloader loader ok-handler))))

+ 9 - 3
src/main/frontend/modules/outliner/pipeline.cljs

@@ -1,6 +1,7 @@
 (ns frontend.modules.outliner.pipeline
   (:require [frontend.modules.datascript-report.core :as ds-report]
-            [frontend.modules.outliner.file :as file]))
+            [frontend.modules.outliner.file :as file]
+            [frontend.state :as state]))
 
 (defn updated-page-hook
   [page]
@@ -8,7 +9,12 @@
 
 (defn invoke-hooks
   [tx-report]
-  (let [{:keys [pages]} (ds-report/get-blocks-and-pages tx-report)]
+  (let [{:keys [pages blocks]} (ds-report/get-blocks-and-pages tx-report)]
     (doseq [p (seq pages)] (updated-page-hook p))
+    (when (and state/lsp-enabled? (seq blocks))
+      (state/pub-event! [:plugin/hook-db-tx
+                         {:blocks  blocks
+                          :tx-data (:tx-data tx-report)
+                          :tx-meta (:tx-meta tx-report)}]))
     ;; TODO: Add blocks to hooks
-    #_(doseq [b (seq blocks)] )))
+    #_(doseq [b (seq blocks)])))

+ 45 - 3
src/main/frontend/state.cljs

@@ -33,7 +33,7 @@
      :notification/show?                    false
      :notification/content                  nil
      :repo/cloning?                         false
-     ;; :repo/loading-files? is only for github repos
+     ;; :repo/loading-files? is only for GitHub repos
      :repo/loading-files?                   {}
      :repo/changed-files                    nil
      :nfs/user-granted?                     {}
@@ -160,8 +160,10 @@
      :plugin/indicator-text                 nil
      :plugin/installed-plugins              {}
      :plugin/installed-themes               []
-     :plugin/installed-commands             {}
+     :plugin/installed-slash-commands       {}
      :plugin/installed-ui-items             {}
+     :plugin/installed-resources            {}
+     :plugin/installed-hooks                {}
      :plugin/simple-commands                {}
      :plugin/selected-theme                 nil
      :plugin/selected-unpacked-pkg          nil
@@ -1294,7 +1296,7 @@
 
 (defn get-plugins-commands
   []
-  (mapcat seq (flatten (vals (:plugin/installed-commands @state)))))
+  (mapcat seq (flatten (vals (:plugin/installed-slash-commands @state)))))
 
 (defn get-plugins-commands-with-type
   [type]
@@ -1306,6 +1308,43 @@
   (filterv #(= (keyword (first %)) (keyword type))
            (apply concat (vals (:plugin/installed-ui-items @state)))))
 
+(defn get-plugin-resources-with-type
+  [pid type]
+  (when-let [pid (and type (keyword pid))]
+    (get-in @state [:plugin/installed-resources pid (keyword type)])))
+
+(defn get-plugin-resource
+  [pid type key]
+  (when-let [resources (get-plugin-resources-with-type pid type)]
+    (get resources key)))
+
+(defn upt-plugin-resource
+  [pid type key attr val]
+  (when-let [resource (get-plugin-resource pid type key)]
+    (let [resource (assoc resource (keyword attr) val)]
+      (set-state!
+        [:plugin/installed-resources (keyword pid) (keyword type) key] resource)
+      resource)))
+
+(defn install-plugin-hook
+  [pid hook]
+  (when-let [pid (keyword pid)]
+    (set-state!
+      [:plugin/installed-hooks hook]
+      (conj
+        ((fnil identity #{}) (get-in @state [:plugin/installed-hooks hook]))
+        pid)) true))
+
+(defn uninstall-plugin-hook
+  [pid hook-or-all]
+  (when-let [pid (keyword pid)]
+    (if (nil? hook-or-all)
+      (swap! state update :plugin/installed-hooks #(medley/map-vals (fn [ids] (disj ids pid)) %))
+      (when-let [coll (get-in @state [:plugin/installed-hooks hook-or-all])]
+        (set-state! [:plugin/installed-hooks hook-or-all] (disj coll pid))))
+    true))
+
+
 (defn get-scheduled-future-days
   []
   (let [days (:scheduled/future-days (get-config))]
@@ -1594,6 +1633,9 @@
   []
   (:plugin/enabled @state))
 
+(def lsp-enabled?
+  (lsp-enabled?-or-theme))
+
 (defn consume-updates-coming-plugin
   [payload updated?]
   (when-let [id (keyword (:id payload))]

+ 187 - 30
src/main/logseq/api.cljs

@@ -28,6 +28,7 @@
             [frontend.state :as state]
             [frontend.util :as util]
             [frontend.util.cursor :as cursor]
+            [frontend.loader :as loader]
             [goog.dom :as gdom]
             [lambdaisland.glogi :as log]
             [medley.core :as medley]
@@ -56,6 +57,20 @@
       (catch js/Error e
         (js/console.error "[parse hiccup error]" e) input))))
 
+(defn ^:export install-plugin-hook
+  [pid hook]
+  (state/install-plugin-hook pid hook))
+
+(defn ^:export uninstall-plugin-hook
+  [pid hook-or-all]
+  (state/uninstall-plugin-hook pid hook-or-all))
+
+(defn ^:export should-exec-plugin-hook
+  [pid hook]
+  (let [hooks (:plugin/installed-hooks @state/state)]
+    (or (nil? (seq hooks))
+        (contains? (get hooks hook) (keyword pid)))))
+
 ;; base
 (defn ^:export get_state_from_store
   [^js path]
@@ -69,16 +84,17 @@
 (def ^:export get_user_configs
   (fn []
     (bean/->js
-     (normalize-keyword-for-json
-      {:preferred-language      (:preferred-language @state/state)
-       :preferred-theme-mode    (:ui/theme @state/state)
-       :preferred-format        (state/get-preferred-format)
-       :preferred-workflow      (state/get-preferred-workflow)
-       :preferred-todo          (state/get-preferred-todo)
-       :preferred-date-format   (state/get-date-formatter)
-       :preferred-start-of-week (state/get-start-of-week)
-       :current-graph           (state/get-current-repo)
-       :me                      (state/get-me)}))))
+      (normalize-keyword-for-json
+        {:preferred-language    (:preferred-language @state/state)
+         :preferred-theme-mode  (:ui/theme @state/state)
+         :preferred-format      (state/get-preferred-format)
+         :preferred-workflow    (state/get-preferred-workflow)
+         :preferred-todo        (state/get-preferred-todo)
+         :preferred-date-format (state/get-date-formatter)
+         :preferred-start-of-week (state/get-start-of-week)
+         :current-graph         (state/get-current-repo)
+         :show-brackets         (state/show-brackets?)
+         :me                    (state/get-me)}))))
 
 (def ^:export get_current_graph
   (fn []
@@ -206,7 +222,7 @@
       (p/let [repo ""
               path (plugin-handler/get-ls-dotdir-root)
               path (util/node-path.join path "preferences.json")]
-        (fs/write-file! repo "" path (js/JSON.stringify data nil 2) {:skip-compare? true})))))
+             (fs/write-file! repo "" path (js/JSON.stringify data nil 2) {:skip-compare? true})))))
 
 (def ^:export load_plugin_user_settings
   ;; results [path data]
@@ -234,19 +250,22 @@
             cmd (assoc cmd :key (string/replace (:key cmd) ":" "-"))
             key (:key cmd)
             keybinding (:keybinding cmd)
-            palette-cmd (and palette? (plugin-handler/simple-cmd->palette-cmd pid cmd action))]
+            palette-cmd (and palette? (plugin-handler/simple-cmd->palette-cmd pid cmd action))
+            action' #(state/pub-event! [:exec-plugin-cmd {:type type :key key :pid pid :cmd cmd :action action}])]
 
         ;; handle simple commands
         (plugin-handler/register-plugin-simple-command pid cmd action)
 
         ;; handle palette commands
-        (when palette-cmd
+        (when palette?
           (palette-handler/register palette-cmd))
 
         ;; handle keybinding commands
-        (when-let [shortcut-args (and palette-cmd keybinding
-                                      (plugin-handler/simple-cmd-keybinding->shortcut-args pid key keybinding))]
-          (let [dispatch-cmd (fn [_ _e] (palette-handler/invoke-command palette-cmd))
+        (when-let [shortcut-args (and keybinding (plugin-handler/simple-cmd-keybinding->shortcut-args pid key keybinding))]
+          (let [dispatch-cmd (fn [_e]
+                               (if palette?
+                                 (palette-handler/invoke-command palette-cmd)
+                                 (action')))
                 [handler-id id shortcut-map] (update shortcut-args 2 assoc :fn dispatch-cmd)]
             (js/console.debug :shortcut/register-shortcut [handler-id id shortcut-map])
             (st/register-shortcut! handler-id id shortcut-map)))))))
@@ -509,27 +528,29 @@
       (get_block (:db/id block) opts))))
 
 (def ^:export get_previous_sibling_block
-  (fn [uuid]
-    (when-let [block (db-model/query-block-by-uuid uuid)]
+  (fn [block-uuid]
+    (when-let [block (db-model/query-block-by-uuid block-uuid)]
       (let [{:block/keys [parent left]} block
             block (when-not (= parent left) (db-utils/pull (:db/id left)))]
         (and block (bean/->js (normalize-keyword-for-json block)))))))
 
 (def ^:export get_next_sibling_block
-  (fn [uuid]
-    (when-let [block (db-model/query-block-by-uuid uuid)]
+  (fn [block-uuid]
+    (when-let [block (db-model/query-block-by-uuid block-uuid)]
       (when-let [right-siblings (outliner/get-right-siblings (outliner/->Block block))]
         (bean/->js (normalize-keyword-for-json (:data (first right-siblings))))))))
 
 (def ^:export set_block_collapsed
-  (fn [uuid ^js opts]
-    (when-let [block (db-model/get-block-by-uuid uuid)]
+  (fn [block-uuid ^js opts]
+    (when-let [block (db-model/get-block-by-uuid block-uuid)]
       (let [{:keys [flag]} (bean/->clj opts)
+            block-uuid (uuid block-uuid)
             flag (if (= "toggle" flag)
                    (not (util/collapsed? block))
                    (boolean flag))]
-        (if flag (editor-handler/collapse-block! uuid)
-                 (editor-handler/expand-block! uuid))))))
+        (if flag (editor-handler/collapse-block! block-uuid)
+                 (editor-handler/expand-block! block-uuid))
+        nil))))
 
 (def ^:export upsert_block_property
   (fn [block-uuid key value]
@@ -566,6 +587,76 @@
             blocks (normalize-keyword-for-json blocks)]
         (bean/->js blocks)))))
 
+(defn ^:export get_page_linked_references
+  [page-name-or-uuid]
+  (when-let [page (and page-name-or-uuid (db-model/get-page page-name-or-uuid))]
+    (let [page-name (:block/name page)
+          ref-blocks (if page-name
+                       (db-model/get-page-referenced-blocks page-name)
+                       (db-model/get-block-referenced-blocks (:block/uuid page)))
+          ref-blocks (and (seq ref-blocks) (into [] ref-blocks))]
+      (bean/->js (normalize-keyword-for-json ref-blocks)))))
+
+(defn ^:export get_pages_from_namespace
+  [ns]
+  (when-let [repo (and ns (state/get-current-repo))]
+    (when-let [pages (db-model/get-namespace-pages repo ns)]
+      (bean/->js (normalize-keyword-for-json pages)))))
+
+(defn ^:export get_pages_tree_from_namespace
+  [ns]
+  (when-let [repo (and ns (state/get-current-repo))]
+    (when-let [pages (db-model/get-namespace-hierarchy repo ns)]
+      (bean/->js (normalize-keyword-for-json pages)))))
+
+(defn first-child-of-block
+  [block]
+  (when-let [children (:block/_parent block)]
+    (first (db-model/sort-by-left children block))))
+
+(defn second-child-of-block
+  [block]
+  (when-let [children (:block/_parent block)]
+    (second (db-model/sort-by-left children block))))
+
+(defn last-child-of-block
+  [block]
+  (when-let [children (:block/_parent block)]
+    (last (db-model/sort-by-left children block))))
+
+(defn ^:export prepend_block_in_page
+  [uuid-or-page-name content ^js opts]
+  (let [page? (not (util/uuid-string? uuid-or-page-name))
+        page-not-exist? (and page? (nil? (db-model/get-page uuid-or-page-name)))
+        _ (and page-not-exist? (page-handler/create! uuid-or-page-name
+                                 {:redirect? false
+                                  :create-first-block? true
+                                  :format (state/get-preferred-format)}))]
+    (when-let [block (db-model/get-page uuid-or-page-name)]
+      (let [block'   (if page? (second-child-of-block block) (first-child-of-block block))
+            sibling? (and page? (not (nil? block')))
+            opts     (bean/->clj opts)
+            opts     (merge opts {:isPageBlock (and page? (not sibling?)) :sibling sibling? :before sibling?})
+            src      (if sibling? (str (:block/uuid block')) uuid-or-page-name)]
+        (insert_block src content (bean/->js opts))))))
+
+(defn ^:export append_block_in_page
+  [uuid-or-page-name content ^js opts]
+  (let [page? (not (util/uuid-string? uuid-or-page-name))
+        page-not-exist? (and page? (nil? (db-model/get-page uuid-or-page-name)))
+        _ (and page-not-exist? (page-handler/create! uuid-or-page-name
+                                 {:redirect? false
+                                  :create-first-block? true
+                                  :format (state/get-preferred-format)}))]
+    (when-let [block (db-model/get-page uuid-or-page-name)]
+      (let [block'   (last-child-of-block block)
+            sibling? (not (nil? block'))
+            opts     (bean/->clj opts)
+            opts     (merge opts {:isPageBlock (and page? (not sibling?)) :sibling sibling?}
+                       (when sibling? {:before false}))
+            src      (if sibling? (str (:block/uuid block')) uuid-or-page-name)]
+        (insert_block src content (bean/->js opts))))))
+
 ;; plugins
 (def ^:export __install_plugin
   (fn [^js manifest]
@@ -612,13 +703,79 @@
   (when-let [args (and args (seq (bean/->clj args)))]
     (shell/run-git-command! args)))
 
-;; helpers
+;; git
+(defn ^:export git_exec_command
+  [^js args]
+  (when-let [args (and args (seq (bean/->clj args)))]
+    (shell/run-git-command2! args)))
+
+(defn ^:export git_load_ignore_file
+  []
+  (when-let [repo (state/get-current-repo)]
+    (p/let [file ".gitignore"
+            dir (config/get-repo-dir repo)
+            _ (fs/create-if-not-exists repo dir file)
+            content (fs/read-file dir file)]
+           content)))
+
+(defn ^:export git_save_ignore_file
+  [content]
+  (when-let [repo (and (string? content) (state/get-current-repo))]
+    (p/let [file ".gitignore"
+            dir (config/get-repo-dir repo)
+            _ (fs/write-file! repo dir file content {:skip-compare? true})])))
+
+;; ui
 (defn ^:export show_msg
-  ([content] (show_msg content :success))
-  ([content status] (let [hiccup? (and (string? content) (string/starts-with? (string/triml content) "[:"))
-                          content (if hiccup? (parse-hiccup-ui content) content)]
-                      (notification/show! content (keyword status)))))
+  ([content] (show_msg content :success nil))
+  ([content status ^js opts]
+   (let [{:keys [key timeout]} (bean/->clj opts)
+         hiccup? (and (string? content) (string/starts-with? (string/triml content) "[:"))
+         content (if hiccup? (parse-hiccup-ui content) content)
+         uid (when (string? key) (keyword key))
+         clear? (not= timeout 0)
+         key' (notification/show! content (keyword status) clear? uid timeout)]
+     (name key'))))
+
+(defn ^:export ui_show_msg
+  [& args]
+  (apply show_msg args))
+
+(defn ^:export ui_close_msg
+  [key]
+  (when (string? key)
+    (notification/clear! (keyword key)) nil))
+
+;; assets
+(defn ^:export assets_list_files_of_current_graph
+  [^js exts]
+  (p/let [files (ipc/ipc :getAssetsFiles {:exts exts})]
+         (bean/->js files)))
+
+;; experiments
+(defn ^:export exper_load_scripts
+  [pid & scripts]
+  (when-let [^js _pl (plugin-handler/get-plugin-inst pid)]
+    (doseq [s scripts
+            :let [upt-status #(state/upt-plugin-resource pid :scripts s :status %)
+                  init? (plugin-handler/register-plugin-resources pid :scripts {:key s :src s})]]
+      (when init?
+        (p/catch
+          (p/then
+            (do
+              (upt-status :pending)
+              (loader/load s nil {:attributes {:data-ref (name pid)}}))
+            #(upt-status :done))
+          #(upt-status :error))))))
+
+(defn ^:export exper_register_fenced_code_renderer
+  [pid type ^js opts]
+  (when-let [^js _pl (plugin-handler/get-plugin-inst pid)]
+    (plugin-handler/register_fenced_code_renderer
+      (keyword pid) type (reduce #(assoc %1 %2 (aget opts (name %2))) {}
+                                 [:edit :before :subs :render]))))
 
+;; helpers
 (defn ^:export query_element_by_id
   [id]
   (let [^js el (gdom/getElement id)]
@@ -633,7 +790,7 @@
 (defn ^:export force_save_graph
   []
   (p/let [_ (el/persist-dbs!)]
-    true))
+         true))
 
 (defn ^:export __debug_state
   [path]

部分文件因为文件数量过多而无法显示