Files
logseq/libs/src/LSPlugin.user.ts
Tienson Qin d8809f0b7e feat: cljs sdk (#12168)
Add clojurescript plugins API
2025-10-21 16:32:12 +08:00

909 lines
21 KiB
TypeScript

import {
isValidUUID,
deepMerge,
mergeSettingsWithSchema,
PluginLogger,
safeSnakeCase,
safetyPathJoin, normalizeKeyStr,
} from './helpers'
import { LSPluginCaller } from './LSPlugin.caller'
import * as callableAPIs from './callable.apis'
import {
IAppProxy,
IDBProxy,
IEditorProxy,
ILSPluginUser,
LSPluginBaseInfo,
LSPluginUserEvents,
SlashCommandAction,
BlockCommandCallback,
StyleString,
Theme,
UIOptions,
IHookEvent,
BlockIdentity,
BlockPageName,
UIContainerAttrs,
SimpleCommandCallback,
SimpleCommandKeybinding,
SettingSchemaDesc,
IUserOffHook,
IGitProxy,
IUIProxy,
UserProxyNSTags,
BlockUUID,
BlockEntity,
IDatom,
IAssetsProxy,
AppInfo,
IPluginSearchServiceHooks,
PageEntity, IUtilsProxy,
} from './LSPlugin'
import Debug from 'debug'
import * as CSS from 'csstype'
import EventEmitter from 'eventemitter3'
import { IAsyncStorage, LSPluginFileStorage } from './modules/LSPlugin.Storage'
import { LSPluginExperiments } from './modules/LSPlugin.Experiments'
import { LSPluginRequest } from './modules/LSPlugin.Request'
import { LSPluginSearchService } from './modules/LSPlugin.Search'
declare global {
interface Window {
__LSP__HOST__: boolean
logseq: LSPluginUser
}
}
type callableMethods = keyof typeof callableAPIs | string // host exported SDK apis & host platform related apis
const PROXY_CONTINUE = Symbol.for('proxy-continue')
const debug = Debug('LSPlugin:user')
const logger = new PluginLogger('', { console: true })
/**
* @param type (key of group commands)
* @param opts
* @param action
*/
function registerSimpleCommand(
this: LSPluginUser,
type: string,
opts: {
key: string
label: string
desc?: string
palette?: boolean
keybinding?: SimpleCommandKeybinding
extras?: Record<string, any>
},
action: SimpleCommandCallback
) {
const { key, label, desc, palette, keybinding, extras } = opts
if (typeof action !== 'function') {
this.logger.error(`${key || label}: command action should be function.`)
return false
}
const normalizedKey = normalizeKeyStr(key)
if (!normalizedKey) {
this.logger.error(`${label}: command key is required.`)
return false
}
const eventKey = `SimpleCommandHook${normalizedKey}${++registeredCmdUid}`
this.Editor['on' + eventKey](action)
this.caller?.call(`api:call`, {
method: 'register-plugin-simple-command',
args: [
this.baseInfo.id,
// [cmd, action]
[
{ key: normalizedKey, label, type, desc, keybinding, extras },
['editor/hook', eventKey],
],
palette,
],
})
}
function shouldValidUUID(uuid: string) {
if (!isValidUUID(uuid)) {
logger.error(`#${uuid} is not a valid UUID string.`)
return false
}
return true
}
function checkEffect(p: LSPluginUser) {
return p && (p.baseInfo?.effect || !p.baseInfo?.iir)
}
let _appBaseInfo: AppInfo = null
let _searchServices: Map<string, LSPluginSearchService> = new Map()
const app: Partial<IAppProxy> = {
async getInfo(this: LSPluginUser, key) {
if (!_appBaseInfo) {
_appBaseInfo = await this._execCallableAPIAsync('get-app-info')
}
return typeof key === 'string' ? _appBaseInfo[key] : _appBaseInfo
},
registerCommand: registerSimpleCommand,
registerSearchService<T extends IPluginSearchServiceHooks>(
this: LSPluginUser,
s: T
) {
if (_searchServices.has(s.name)) {
throw new Error(`SearchService: #${s.name} has registered!`)
}
_searchServices.set(s.name, new LSPluginSearchService(this, s))
},
registerCommandPalette(
opts: { key: string; label: string; keybinding?: SimpleCommandKeybinding },
action: SimpleCommandCallback
) {
const { key, label, keybinding } = opts
const group = '$palette$'
return registerSimpleCommand.call(
this,
group,
{ key, label, palette: true, keybinding },
action
)
},
registerCommandShortcut(
keybinding: SimpleCommandKeybinding | string,
action: SimpleCommandCallback,
opts: Partial<{
key: string
label: string
desc: string
extras: Record<string, any>
}> = {}
) {
if (typeof keybinding == 'string') {
keybinding = {
mode: 'global',
binding: keybinding,
}
}
const { binding } = keybinding
const group = '$shortcut$'
const key = opts.key || (group + safeSnakeCase(binding?.toString()))
return registerSimpleCommand.call(
this,
group,
{ ...opts, key, palette: false, keybinding },
action
)
},
registerUIItem(
type: 'toolbar' | 'pagebar',
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],
})
},
registerPageMenuItem(
this: LSPluginUser,
tag: string,
action: (e: IHookEvent & { page: string }) => void
) {
if (typeof action !== 'function') {
return false
}
const key = tag + '_' + this.baseInfo.id
const label = tag
const type = 'page-menu-item'
registerSimpleCommand.call(
this,
type,
{
key,
label,
},
action
)
},
onBlockRendererSlotted(uuid, callback: (payload: any) => void) {
if (!shouldValidUUID(uuid)) return
const pid = this.baseInfo.id
const hook = `hook:editor:${safeSnakeCase(`slot:${uuid}`)}`
this.caller.on(hook, callback)
this.App._installPluginHook(pid, hook)
return () => {
this.caller.off(hook, callback)
this.App._uninstallPluginHook(pid, hook)
}
},
invokeExternalPlugin(this: LSPluginUser, type: string, ...args: Array<any>) {
type = type?.trim()
if (!type) return
let [pid, group] = type.split('.')
if (!['models', 'commands'].includes(group?.toLowerCase())) {
throw new Error(`Type only support '.models' or '.commands' currently.`)
}
const key = type.replace(`${pid}.${group}.`, '')
if (!pid || !group || !key) {
throw new Error(`Illegal type of #${type} to invoke external plugin.`)
}
return this._execCallableAPIAsync(
'invoke_external_plugin_cmd',
pid,
group.toLowerCase(),
key,
args
)
},
setFullScreen(flag) {
const sf = (...args) => this._callWin('setFullScreen', ...args)
if (flag === 'toggle') {
this._callWin('isFullScreen').then((r) => {
r ? sf() : sf(true)
})
} else {
flag ? sf(true) : sf()
}
},
}
let registeredCmdUid = 0
const editor: Partial<IEditorProxy> = {
newBlockUUID(this: LSPluginUser): Promise<string> {
return this._execCallableAPIAsync('new_block_uuid')
},
isPageBlock(
this: LSPluginUser,
block: BlockEntity | PageEntity
): Boolean {
return block.uuid && block.hasOwnProperty('name')
},
registerSlashCommand(
this: LSPluginUser,
tag: string,
actions: BlockCommandCallback | Array<SlashCommandAction>
) {
debug('Register slash command #', this.baseInfo.id, tag, actions)
if (typeof actions === 'function') {
actions = [
['editor/clear-current-slash', false],
['editor/restore-saved-cursor'],
['editor/hook', actions],
]
}
actions = actions.map((it) => {
const [tag, ...args] = it
switch (tag) {
case 'editor/hook':
let key = args[0]
let fn = () => {
this.caller?.callUserModel(key)
}
if (typeof key === 'function') {
fn = key
}
const eventKey = `SlashCommandHook${tag}${++registeredCmdUid}`
it[1] = eventKey
// register command listener
this.Editor['on' + eventKey](fn)
break
default:
}
return it
})
this.caller?.call(`api:call`, {
method: 'register-plugin-slash-command',
args: [this.baseInfo.id, [tag, actions]],
})
},
registerBlockContextMenuItem(
this: LSPluginUser,
label: string,
action: BlockCommandCallback
) {
if (typeof action !== 'function') {
return false
}
const key = label + '_' + this.baseInfo.id
const type = 'block-context-menu-item'
registerSimpleCommand.call(
this,
type,
{
key,
label,
},
action
)
},
registerHighlightContextMenuItem(
this: LSPluginUser,
label: string,
action: SimpleCommandCallback,
opts?: { clearSelection: boolean }
) {
if (typeof action !== 'function') {
return false
}
const key = label + '_' + this.baseInfo.id
const type = 'highlight-context-menu-item'
registerSimpleCommand.call(
this,
type,
{
key,
label,
extras: opts,
},
action
)
},
scrollToBlockInPage(
this: LSPluginUser,
pageName: BlockPageName,
blockId: BlockIdentity,
opts?: { replaceState: boolean }
) {
const anchor = `block-content-` + blockId
if (opts?.replaceState) {
this.App.replaceState('page', { name: pageName }, { anchor })
} else {
this.App.pushState('page', { name: pageName }, { anchor })
}
},
}
const db: Partial<IDBProxy> = {
onBlockChanged(
this: LSPluginUser,
uuid: BlockUUID,
callback: (
block: BlockEntity,
txData: Array<IDatom>,
txMeta?: { outlinerOp: string; [p: string]: any }
) => void
): IUserOffHook {
if (!shouldValidUUID(uuid)) return
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)
}
},
datascriptQuery<T = any>(
this: LSPluginUser,
query: string,
...inputs: Array<any>
): Promise<T> {
// force remove proxy ns flag `db`
inputs.pop()
if (inputs?.some((it) => typeof it === 'function')) {
const host = this.Experiments.ensureHostScope()
return host.logseq.api.datascript_query(query, ...inputs)
}
return this._execCallableAPIAsync(`datascript_query`, ...[query, ...inputs])
},
}
const git: Partial<IGitProxy> = {}
const ui: Partial<IUIProxy> = {}
const utils: Partial<IUtilsProxy> = {}
const assets: Partial<IAssetsProxy> = {
makeSandboxStorage(this: LSPluginUser): IAsyncStorage {
return new LSPluginFileStorage(this, { assets: true })
},
}
type uiState = {
key?: number
visible: boolean
}
const KEY_MAIN_UI = 0
/**
* User plugin instance from global namespace `logseq`.
* @example
* ```ts
* logseq.UI.showMsg('Hello, Logseq')
* ```
* @public
*/
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
/**
* ui frame identities
* @private
*/
private _ui = new Map<number, uiState>()
private _mFileStorage: LSPluginFileStorage
private _mRequest: LSPluginRequest
private _mExperiments: LSPluginExperiments
/**
* handler of before unload plugin
* @private
*/
private _beforeunloadCallback?: (e: any) => Promise<void>
/**
* @param _baseInfo
* @param _caller
*/
constructor(
private _baseInfo: LSPluginBaseInfo,
private _caller: LSPluginCaller
) {
super()
_caller.on('sys:ui:visible', (payload) => {
if (payload?.toggle) {
this.toggleMainUI()
}
})
_caller.on('settings:changed', (payload) => {
const b = Object.assign({}, this.settings)
const a = Object.assign(this._baseInfo.settings, payload)
this.emit('settings:changed', { ...a }, b)
})
_caller.on('beforeunload', async (payload) => {
const { actor, ...rest } = payload
const cb = this._beforeunloadCallback
try {
cb && (await cb(rest))
actor?.resolve(null)
} catch (e) {
this.logger.error(`[beforeunload] `, e)
actor?.reject(e)
}
})
}
// Life related
async ready(model?: any, callback?: any) {
if (this._connected) return
try {
if (typeof model === 'function') {
callback = model
model = {}
}
let baseInfo = await this._caller.connectToParent(model)
this._connected = true
baseInfo = deepMerge(this._baseInfo, baseInfo)
this._baseInfo = baseInfo
if (baseInfo?.id) {
this._debugTag =
this._caller.debugTag = `#${baseInfo.id} [${baseInfo.name}]`
this.logger.setTag(this._debugTag)
}
if (this._settingsSchema) {
baseInfo.settings = mergeSettingsWithSchema(
baseInfo.settings,
this._settingsSchema
)
// TODO: sync host settings schema
await this.useSettingsSchema(this._settingsSchema)
}
try {
await this._execCallableAPIAsync('setSDKMetadata', {
version: this._version,
runtime: 'js',
})
} catch (e) {
console.warn(e)
}
callback && callback.call(this, baseInfo)
} catch (e) {
console.error(`${this._debugTag} [Ready Error]`, e)
}
}
ensureConnected() {
if (!this._connected) {
throw new Error('not connected')
}
}
beforeunload(callback: (e: any) => Promise<void>): void {
if (typeof callback !== 'function') return
this._beforeunloadCallback = callback
}
provideModel(model: Record<string, any>) {
this.caller._extendUserModel(model)
return this
}
provideTheme(theme: Theme) {
this.caller.call('provider:theme', theme)
return this
}
provideStyle(style: StyleString) {
this.caller.call('provider:style', style)
return this
}
provideUI(ui: UIOptions) {
this.caller.call('provider:ui', ui)
return this
}
// Settings related
useSettingsSchema(schema: Array<SettingSchemaDesc>) {
if (this.connected) {
this.caller.call('settings:schema', {
schema,
isSync: true,
})
}
this._settingsSchema = schema
return this
}
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 {
const type = 'settings:changed'
this.on(type, cb)
return () => this.off(type, cb)
}
showSettingsUI() {
this.caller.call('settings:visible:changed', { visible: true })
}
hideSettingsUI() {
this.caller.call('settings:visible:changed', { visible: false })
}
// UI related
setMainUIAttrs(attrs: Partial<UIContainerAttrs>): void {
this.caller.call('main-ui:attrs', attrs)
}
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,
}
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,
}
this.caller.call('main-ui:visible', payload)
this.emit('ui:visible:changed', payload)
this._ui.set(payload.key, payload)
}
toggleMainUI(): void {
const payload = { key: KEY_MAIN_UI, toggle: true }
const state = this._ui.get(payload.key)
if (state && state.visible) {
this.hideMainUI()
} else {
this.showMainUI()
}
}
// Getters
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 {
return this._connected
}
get baseInfo(): LSPluginBaseInfo {
return this._baseInfo
}
get effect(): Boolean {
return checkEffect(this)
}
get logger() {
return logger
}
get settings() {
return this.baseInfo?.settings
}
get caller(): LSPluginCaller {
return this._caller
}
resolveResourceFullUrl(filePath: string) {
this.ensureConnected()
if (!filePath) return
filePath = filePath.replace(/^[.\\/]+/, '')
return safetyPathJoin(this._baseInfo.lsr, filePath)
}
/**
* @internal
*/
_makeUserProxy(target: any, nstag?: UserProxyNSTags) {
const that = this
const caller = this.caller
return new Proxy(target, {
get(target: any, propKey, _receiver) {
const origMethod = target[propKey]
return function (this: any, ...args: any) {
if (origMethod) {
if (args?.length !== 0) args.concat(nstag)
const ret = origMethod.apply(that, args)
if (ret !== PROXY_CONTINUE) return ret
}
// Handle hook
if (nstag) {
const hookMatcher = propKey.toString().match(/^(once|off|on)/i)
if (hookMatcher != null) {
const f = hookMatcher[0].toLowerCase()
const s = hookMatcher.input!
const isOff = f === 'off'
const pid = that.baseInfo.id
let type = s.slice(f.length)
let handler = args[0]
let opts = args[1]
// condition mode
if (typeof handler === 'string' && typeof opts === 'function') {
handler = handler.replace(/^logseq./, ':')
type = `${type}${handler}`
handler = opts
opts = args[2]
}
type = `hook:${nstag}:${safeSnakeCase(type)}`
caller[f](type, handler)
const unlisten = () => {
caller.off(type, handler)
if (!caller.listenerCount(type)) {
that.App._uninstallPluginHook(pid, type)
}
}
if (!isOff) {
that.App._installPluginHook(pid, type, opts)
} else {
unlisten()
return
}
return unlisten
}
}
let method = propKey as string
// TODO: refactor api call with the explicit tag
if ((['git', 'ui', 'assets', 'utils'] as UserProxyNSTags[]).includes(nstag)) {
method = nstag + '_' + method
}
// Call host
return caller.callAsync(`api:call`, {
tag: nstag,
method,
args: args,
})
}
},
})
}
_execCallableAPIAsync(method: callableMethods, ...args) {
return this._caller.callAsync(`api:call`, {
method,
args,
})
}
_execCallableAPI(method: callableMethods, ...args) {
this._caller.call(`api:call`, {
method,
args,
})
}
_callWin(...args) {
return this._execCallableAPIAsync(`_callMainWin`, ...args)
}
// User Proxies
#appProxy: IAppProxy
#editorProxy: IEditorProxy
#dbProxy: IDBProxy
#uiProxy: IUIProxy
#utilsProxy: IUtilsProxy
get App(): IAppProxy {
if (this.#appProxy) return this.#appProxy
return (this.#appProxy = this._makeUserProxy(app, 'app'))
}
get Editor(): IEditorProxy {
if (this.#editorProxy) return this.#editorProxy
return (this.#editorProxy = this._makeUserProxy(editor, 'editor'))
}
get DB(): IDBProxy {
if (this.#dbProxy) return this.#dbProxy
return (this.#dbProxy = this._makeUserProxy(db, 'db'))
}
get UI(): IUIProxy {
if (this.#uiProxy) return this.#uiProxy
return (this.#uiProxy = this._makeUserProxy(ui, 'ui'))
}
get Utils(): IUtilsProxy {
if (this.#utilsProxy) return this.#utilsProxy
return (this.#utilsProxy = this._makeUserProxy(utils, 'utils'))
}
get Git(): IGitProxy {
return this._makeUserProxy(git, 'git')
}
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 Request(): LSPluginRequest {
let m = this._mRequest
if (!m) m = this._mRequest = new LSPluginRequest(this)
return m
}
get Experiments(): LSPluginExperiments {
let m = this._mExperiments
if (!m) m = this._mExperiments = new LSPluginExperiments(this)
return m
}
}
export * from './LSPlugin'
/**
* @internal
*/
export function setupPluginUserInstance(
pluginBaseInfo: LSPluginBaseInfo,
pluginCaller: LSPluginCaller
) {
return new LSPluginUser(pluginBaseInfo, pluginCaller)
}
// entry of iframe mode
if (window.__LSP__HOST__ == null) {
const caller = new LSPluginCaller(null)
window.logseq = setupPluginUserInstance({} as any, caller)
}