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 }, 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 = new Map() const app: Partial = { async getInfo(this: LSPluginUser, key) { if (!_appBaseInfo) { _appBaseInfo = await this._execCallableAPIAsync('get-app-info') } return typeof key === 'string' ? _appBaseInfo[key] : _appBaseInfo }, registerCommand: registerSimpleCommand, registerSearchService( 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 }> = {} ) { 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) { 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 = { newBlockUUID(this: LSPluginUser): Promise { 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 ) { 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 = { onBlockChanged( this: LSPluginUser, uuid: BlockUUID, callback: ( block: BlockEntity, txData: Array, 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( this: LSPluginUser, query: string, ...inputs: Array ): Promise { // 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 = {} const ui: Partial = {} const utils: Partial = {} const assets: Partial = { 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 implements ILSPluginUser { // @ts-ignore private _version: string = LIB_VERSION private _debugTag: string = '' private _settingsSchema?: Array private _connected: boolean = false /** * ui frame identities * @private */ private _ui = new Map() private _mFileStorage: LSPluginFileStorage private _mRequest: LSPluginRequest private _mExperiments: LSPluginExperiments /** * handler of before unload plugin * @private */ private _beforeunloadCallback?: (e: any) => Promise /** * @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 { if (typeof callback !== 'function') return this._beforeunloadCallback = callback } provideModel(model: Record) { 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) { if (this.connected) { this.caller.call('settings:schema', { schema, isSync: true, }) } this._settingsSchema = schema return this } updateSettings(attrs: Record) { this.caller.call('settings:update', attrs) // TODO: update associated baseInfo settings } onSettingsChanged(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): 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) }