feat(plugin): merge implementation

This commit is contained in:
charlie
2021-05-07 16:54:14 +08:00
parent 7a78e805fd
commit f3edb9b77f
20 changed files with 4292 additions and 1 deletions

1
.gitignore vendored
View File

@@ -31,3 +31,4 @@ strings.csv
resources/electron.js
.clj-kondo/
.lsp/
/libs/dist/

View File

@@ -34,7 +34,8 @@
thheller/shadow-cljs {:mvn/version "2.12.5"}
expound/expound {:mvn/version "0.8.6"}
com.lambdaisland/glogi {:mvn/version "1.0.116"}
binaryage/devtools {:mvn/version "1.0.2"}}
binaryage/devtools {:mvn/version "1.0.2"}
camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.2"}}
:aliases {:cljs {:extra-paths ["src/dev-cljs/" "src/test/" "src/electron/"]
:extra-deps {org.clojure/clojurescript {:mvn/version "1.10.844"}

2
libs/.npmignore Normal file
View File

@@ -0,0 +1,2 @@
src/
webpack.*

17
libs/README.md Normal file
View File

@@ -0,0 +1,17 @@
## @logseq/libs
🚀 Logseq SDK libraries [WIP].
#### Installation
```shell
yarn add @logseq/libs
```
#### Usage
Load `logseq` plugin sdk as global namespace
```js
import "@logseq/libs"
```

5
libs/index.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
import { ILSPluginUser } from './dist/LSPlugin'
declare global {
var logseq: ILSPluginUser
}

34
libs/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "@logseq/libs",
"version": "0.0.1-alpha.6",
"description": "Logseq SDK libraries",
"main": "dist/lsplugin.user.js",
"typings": "index.d.ts",
"private": false,
"scripts": {
"build:user": "webpack --mode production",
"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 && cp src/*.d.ts dist/ && npm run build:user"
},
"dependencies": {
"debug": "^4.3.1",
"dompurify": "^2.2.7",
"eventemitter3": "^4.0.7",
"path": "^0.12.7",
"postmate": "^1.5.2",
"snake-case": "^3.0.4"
},
"devDependencies": {
"@types/debug": "^4.1.5",
"@types/dompurify": "^2.2.1",
"@types/lodash-es": "^4.17.4",
"@types/postmate": "^1.5.1",
"ts-loader": "^8.0.17",
"typescript": "^4.2.2",
"webpack": "^5.24.3",
"webpack-bundle-analyzer": "^4.4.0",
"webpack-cli": "^4.5.0"
}
}

286
libs/src/LSPlugin.caller.ts Normal file
View File

@@ -0,0 +1,286 @@
import Postmate from 'postmate'
import EventEmitter from 'eventemitter3'
import { PluginLocal } from './LSPlugin.core'
import Debug from 'debug'
import { deferred } from './helpers'
import { LSPluginShadowFrame } from './LSPlugin.shadow'
const debug = Debug('LSPlugin:caller')
type DeferredActor = ReturnType<typeof deferred>
export const LSPMSG = '#lspmsg#'
export const LSPMSG_SETTINGS = '#lspmsg#settings#'
export const LSPMSG_SYNC = '#lspmsg#reply#'
export const LSPMSG_READY = '#lspmsg#ready#'
export const LSPMSGFn = (id: string) => `${LSPMSG}${id}`
/**
* Call between core and user
*/
class LSPluginCaller extends EventEmitter {
private _connected: boolean = false
private _parent?: Postmate.ParentAPI
private _child?: Postmate.ChildAPI
private _shadow?: LSPluginShadowFrame
private _status?: 'pending' | 'timeout'
private _userModel: any = {}
private _call?: (type: string, payload: any, actor?: DeferredActor) => Promise<any>
private _callUserModel?: (type: string, payload: any) => Promise<any>
constructor (
private _pluginLocal: PluginLocal | null
) {
super()
}
async connectToChild () {
if (this._connected) return
const { shadow } = this._pluginLocal!
if (shadow) {
await this._setupShadowSandbox()
} else {
await this._setupIframeSandbox()
}
}
async connectToParent (userModel = {}) {
if (this._connected) return
const caller = this
const isShadowMode = this._pluginLocal != null
let syncGCTimer: any = 0
let syncTag = 0
const syncActors = new Map<number, DeferredActor>()
const readyDeferred = deferred()
const model: any = this._extendUserModel({
[LSPMSG_READY]: async () => {
await readyDeferred.resolve()
},
[LSPMSG_SETTINGS]: async ({ type, payload }) => {
caller.emit('settings:changed', payload)
},
[LSPMSG]: async ({ ns, type, payload }: any) => {
debug(`[call from host #${this._pluginLocal?.id}]`, ns, type, payload)
if (ns && ns.startsWith('hook')) {
caller.emit(`${ns}:${type}`, payload)
return
}
caller.emit(type, payload)
},
[LSPMSG_SYNC]: ({ _sync, result }: any) => {
debug(`sync reply #${_sync}`, result)
if (syncActors.has(_sync)) {
// TODO: handle exception
syncActors.get(_sync)?.resolve(result)
syncActors.delete(_sync)
}
},
...userModel
})
if (isShadowMode) {
await readyDeferred.promise
return JSON.parse(JSON.stringify(this._pluginLocal?.toJSON()))
}
const handshake = new Postmate.Model(model)
this._status = 'pending'
await handshake.then(refParent => {
this._child = refParent
this._connected = true
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)
}
refParent.emit(LSPMSGFn(model.baseInfo.id), { type, payload })
return actor?.promise as Promise<any>
}
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)
}
}
}
}, 1000 * 60 * 30)
}).finally(() => {
this._status = undefined
})
// TODO: timeout
await readyDeferred.promise
return model.baseInfo
}
async call (type: any, payload: any = {}) {
// TODO: ?
this.emit(type, payload)
return this._call?.call(this, type, payload)
}
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 = {}) {
return this._callUserModel?.call(this, type, payload)
}
async _setupIframeSandbox () {
const pl = this._pluginLocal!
const handshake = new Postmate({
container: document.body,
url: pl.options.entry!,
classListArray: ['lsp-iframe-sandbox'],
model: { baseInfo: JSON.parse(JSON.stringify(pl.toJSON())) }
})
this._status = 'pending'
// timeout for handshake
let timer
return new Promise((resolve, reject) => {
timer = setTimeout(() => {
reject(new Error(`handshake Timeout`))
}, 3 * 1000) // 3secs
handshake.then(refChild => {
this._parent = refChild
this._connected = true
this.emit('connected')
refChild.frame.setAttribute('id', pl.id)
refChild.on(LSPMSGFn(pl.id), ({ type, payload }: any) => {
debug(`[call from plugin] `, type, payload)
this._pluginLocal?.emit(type, payload || {})
})
this._call = async (...args: any) => {
// parent all will get message
await refChild.call(LSPMSGFn(pl.id), { type: args[0], payload: args[1] || {} })
}
this._callUserModel = async (...args: any) => {
await refChild.call(args[0], args[1] || {})
}
resolve(null)
}).catch(e => {
reject(e)
}).finally(() => {
clearTimeout(timer)
})
}).catch(e => {
debug('iframe sandbox error', e)
throw e
}).finally(() => {
this._status = undefined
})
}
async _setupShadowSandbox () {
const pl = this._pluginLocal!
const shadow = this._shadow = new LSPluginShadowFrame(pl)
try {
this._status = 'pending'
await shadow.load()
this._connected = true
this.emit('connected')
this._call = async (type, payload = {}, actor) => {
actor && (payload.actor = actor)
// TODO: support sync call
// @ts-ignore Call in same thread
this._pluginLocal?.emit(type, payload)
return actor?.promise
}
this._callUserModel = async (...args: any) => {
const type = args[0]
const payload = args[1] || {}
const fn = this._userModel[type]
if (typeof fn === 'function') {
await fn.call(null, payload)
}
}
} catch (e) {
debug('shadow sandbox error', e)
throw e
} finally {
this._status = undefined
}
}
_extendUserModel (model: any) {
return Object.assign(this._userModel, model)
}
_getSandboxIframeContainer () {
return this._parent?.frame
}
_getSandboxShadowContainer () {
return this._shadow?.frame
}
async destroy () {
if (this._parent) {
await this._parent.destroy()
}
if (this._shadow) {
this._shadow.destroy()
}
}
}
export {
LSPluginCaller
}

1001
libs/src/LSPlugin.core.ts Normal file

File diff suppressed because it is too large Load Diff

196
libs/src/LSPlugin.d.ts vendored Normal file
View File

@@ -0,0 +1,196 @@
import EventEmitter from 'eventemitter3'
import { LSPluginCaller } from './LSPlugin.caller'
import { LSPluginUser } from './LSPlugin.user'
type PluginLocalIdentity = string
type ThemeOptions = {
name: string
url: string
description?: string
mode?: 'dark' | 'light'
[key: string]: any
}
type StyleString = string
type StyleOptions = {
key?: string
style: StyleString
}
type UIBaseOptions = {
key?: string
replace?: boolean
template: string
}
type UIPathIdentity = {
path: string // dom selector
}
type UISlotIdentity = {
slot: string // slot key
}
type UISlotOptions = UIBaseOptions & UISlotIdentity
type UIPathOptions = UIBaseOptions & UIPathIdentity
type UIOptions = UIPathOptions & UISlotOptions
interface LSPluginPkgConfig {
id: PluginLocalIdentity
mode: 'shadow' | 'iframe'
themes: Array<ThemeOptions>
icon: string
}
interface LSPluginBaseInfo {
id: string // should be unique
mode: 'shadow' | 'iframe'
settings: {
disabled: boolean
[key: string]: any
},
[key: string]: any
}
type IHookEvent = {
[key: string]: any
}
type IUserHook = (callback: (e: IHookEvent) => void) => void
type IUserSlotHook = (callback: (e: IHookEvent & UISlotIdentity) => void) => void
interface BlockEntity {
uuid: string
content: string
[key: string]: any
}
type BlockIdentity = 'string' | Pick<BlockEntity, 'uuid'>
type SlashCommandActionTag = 'editor/input' | 'editor/hook' | 'editor/clear-current-slash'
type SlashCommandAction = [SlashCommandActionTag, ...args: any]
interface IAppProxy {
pushState: (k: string, params?: {}) => void
replaceState: (k: string, params?: {}) => void
getUserState: () => Promise<any>
showMsg: (content: string, status?: 'success' | 'warning' | string) => void
setZoomFactor: (factor: number) => void
onThemeModeChanged: IUserHook
onPageFileMounted: IUserSlotHook
onBlockRendererMounted: IUserSlotHook
}
interface IEditorProxy {
registerSlashCommand: (this: LSPluginUser, tag: string, actions: Array<SlashCommandAction>) => boolean
registerBlockContextMenu: (this: LSPluginUser, tag: string, action: () => void) => boolean
// TODO: Block related APIs
getCurrentBlock: () => Promise<BlockIdentity>
getCurrentPageBlocksTree: <T = any> () => Promise<T>
insertBlock: (srcBlock: BlockIdentity, content: string, opts: Partial<{ before: boolean, sibling: boolean, props: {} }>) => Promise<BlockIdentity>
updateBlock: (srcBlock: BlockIdentity, content: string, opts: Partial<{ props: {} }>) => Promise<void>
removeBlock: (srcBlock: BlockIdentity, opts: Partial<{ includeChildren: boolean }>) => Promise<void>
touchBlock: (srcBlock: BlockIdentity) => Promise<BlockIdentity>
moveBlock: (srcBlock: BlockIdentity, targetBlock: BlockIdentity, opts: Partial<{ before: boolean, sibling: boolean }>) => Promise<void>
updateBlockProperty: (block: BlockIdentity, key: string, value: any) => Promise<void>
removeBlockProperty: (block: BlockIdentity) => Promise<void>
}
interface IDBProxy {
datascriptQuery: <T = any>(query: string) => Promise<T>
}
interface ILSPluginThemeManager extends EventEmitter {
themes: Map<PluginLocalIdentity, Array<ThemeOptions>>
registerTheme (id: PluginLocalIdentity, opt: ThemeOptions): Promise<void>
unregisterTheme (id: PluginLocalIdentity): Promise<void>
selectTheme (opt?: ThemeOptions): Promise<void>
}
type LSPluginUserEvents = 'ui:visible:changed' | 'settings:changed'
interface ILSPluginUser extends EventEmitter<LSPluginUserEvents> {
/**
* Indicate connected with host
*/
connected: boolean
/**
* Duplex message caller
*/
caller: LSPluginCaller
/**
* Most from packages
*/
baseInfo: LSPluginBaseInfo
/**
* Plugin user settings
*/
settings?: LSPluginBaseInfo['settings']
/**
* Ready for host connected
*/
ready (model?: Record<string, any>): Promise<any>
ready (callback?: (e: any) => void | {}): Promise<any>
ready (model?: Record<string, any>, callback?: (e: any) => void | {}): Promise<any>
/**
* @param model
*/
provideModel (model: Record<string, any>): this
/**
* @param theme options
*/
provideTheme (theme: ThemeOptions): this
/**
* @param style
*/
provideStyle (style: StyleString | StyleOptions): this
/**
* @param ui options
*/
provideUI (ui: UIOptions): this
/**
* @param attrs
*/
updateSettings (attrs: Record<string, any>): void
/**
* MainUI for index.html
* @param attrs
*/
setMainUIAttrs (attrs: Record<string, any>): void
setMainUIInlineStyle (style: CSSStyleDeclaration): void
showMainUI (): void
hideMainUI (): void
toggleMainUI (): void
isMainUIVisible: boolean
App: IAppProxy
Editor: IEditorProxy
DB: IDBProxy
}

116
libs/src/LSPlugin.shadow.ts Normal file
View File

@@ -0,0 +1,116 @@
import EventEmitter from 'eventemitter3'
import { PluginLocal } from './LSPlugin.core'
import { LSPluginUser } from './LSPlugin.user'
// @ts-ignore
const { importHTML, createSandboxContainer } = window.QSandbox || {}
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 () {
return content
}
})
} catch (e) {
console.error(e)
reject(e)
}
})
}
return fetch(url, opts)
}
class LSPluginShadowFrame extends EventEmitter<'mounted' | 'unmounted'> {
private _frame?: HTMLElement
private _root?: ShadowRoot
private _loaded = false
private _unmountFns: Array<() => Promise<void>> = []
constructor (
private _pluginLocal: PluginLocal
) {
super()
_pluginLocal._dispose(() => {
this._unmount()
})
}
async load () {
const { name, entry } = this._pluginLocal.options
if (this.loaded || !entry) return
const { template, execScripts } = await importHTML(entry, { fetch: userFetch })
this._mount(template, document.body)
const sandbox = createSandboxContainer(
name, {
elementGetter: () => this._root?.firstChild,
}
)
const global = sandbox.instance.proxy as any
global.__shadow_mode__ = true
global.LSPluginLocal = this._pluginLocal
global.LSPluginShadow = this
global.LSPluginUser = global.logseq = new LSPluginUser(
this._pluginLocal.toJSON() as any,
this._pluginLocal.caller!
)
// TODO: {mount, unmount}
const execResult: any = await execScripts(global, true)
this._unmountFns.push(execResult.unmount)
this._loaded = true
}
_mount (content: string, container: HTMLElement) {
const frame = this._frame = document.createElement('div')
frame.classList.add('lsp-shadow-sandbox')
frame.id = this._pluginLocal.id
this._root = frame.attachShadow({ mode: 'open' })
this._root.innerHTML = `<div>${content}</div>`
container.appendChild(frame)
this.emit('mounted')
}
_unmount () {
for (const fn of this._unmountFns) {
fn && fn.call(null)
}
}
destroy () {
this.frame?.parentNode?.removeChild(this.frame)
}
get loaded (): boolean {
return this._loaded
}
get document () {
return this._root?.firstChild as HTMLElement
}
get frame (): HTMLElement {
return this._frame!
}
}
export {
LSPluginShadowFrame
}

303
libs/src/LSPlugin.user.ts Normal file
View File

@@ -0,0 +1,303 @@
import { deepMerge, invokeHostExportedApi } from './helpers'
import { LSPluginCaller } from './LSPlugin.caller'
import {
IAppProxy, IDBProxy,
IEditorProxy,
ILSPluginUser,
LSPluginBaseInfo, LSPluginUserEvents, SlashCommandAction,
StyleString,
ThemeOptions,
UIOptions
} from './LSPlugin'
import Debug from 'debug'
import { snakeCase } from 'snake-case'
import EventEmitter from 'eventemitter3'
declare global {
interface Window {
__LSP__HOST__: boolean
logseq: ILSPluginUser
}
}
const debug = Debug('LSPlugin:user')
const app: Partial<IAppProxy> = {}
let registeredCmdUid = 0
const editor: Partial<IEditorProxy> = {
registerSlashCommand (
this: LSPluginUser,
tag: string,
actions: Array<SlashCommandAction>
) {
debug('Register slash command #', this.baseInfo.id, tag, 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]]
})
return false
},
registerBlockContextMenu (
this: LSPluginUser,
tag: string,
action: () => void
): boolean {
if (typeof action !== 'function') {
return false
}
const key = tag
const label = tag
const type = 'block-context-menu'
const eventKey = `SimpleCommandHook${tag}${++registeredCmdUid}`
this.Editor['on' + eventKey](action)
this.caller?.call(`api:call`, {
method: 'register-plugin-simple-command',
args: [this.baseInfo.id, [{ key, label, type }, ['editor/hook', eventKey]]]
})
return false
}
}
const db: Partial<IDBProxy> = {}
type uiState = {
key?: number,
visible: boolean
}
const KEY_MAIN_UI = 0
/**
* User plugin instance
*/
export class LSPluginUser extends EventEmitter<LSPluginUserEvents> implements ILSPluginUser {
/**
* Indicate connected with host
* @private
*/
private _connected: boolean = false
private _ui = new Map<number, uiState>()
/**
* @param _baseInfo
* @param _caller
*/
constructor (
private _baseInfo: LSPluginBaseInfo,
private _caller: LSPluginCaller
) {
super()
_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)
})
}
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)
baseInfo = deepMerge(this._baseInfo, baseInfo)
this._connected = true
callback && callback.call(this, baseInfo)
} catch (e) {
console.error('[LSPlugin Ready Error]', e)
}
}
provideModel (model: Record<string, any>) {
this.caller._extendUserModel(model)
return this
}
provideTheme (theme: ThemeOptions) {
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
}
updateSettings (attrs: Record<string, any>) {
this.caller.call('settings:update', attrs)
// TODO: update associated baseInfo settings
}
setMainUIAttrs (attrs: Record<string, any>): void {
this.caller.call('main-ui:attrs', attrs)
}
setMainUIInlineStyle (style: CSSStyleDeclaration): void {
this.caller.call('main-ui:style', style)
}
hideMainUI (): void {
const payload = { key: KEY_MAIN_UI, visible: false }
this.caller.call('main-ui:visible', payload)
this.emit('ui:visible:changed', payload)
this._ui.set(payload.key, payload)
}
showMainUI (): void {
const payload = { key: KEY_MAIN_UI, visible: true }
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()
}
}
get isMainUIVisible (): boolean {
const state = this._ui.get(0)
return Boolean(state && state.visible)
}
get connected (): boolean {
return this._connected
}
get baseInfo (): LSPluginBaseInfo {
return this._baseInfo
}
get settings () {
return this.baseInfo?.settings
}
get caller (): LSPluginCaller {
return this._caller
}
_makeUserProxy (
target: any,
tag?: 'app' | 'editor' | 'db'
) {
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) {
const ret = origMethod.apply(that, args)
if (ret === false) return
}
// Handle hook
if (tag) {
const hookMatcher = propKey.toString().match(/^(once|off|on)/i)
if (hookMatcher != null) {
const f = hookMatcher[0]
const s = hookMatcher.input!
const e = s.slice(f.length)
caller[f.toLowerCase()](`hook:${tag}:${snakeCase(e)}`, args[0])
return
}
}
// Call host
return caller.callAsync(`api:call`, {
method: propKey,
args: args
})
}
}
})
}
get App (): IAppProxy {
return this._makeUserProxy(app, 'app')
}
get Editor () {
return this._makeUserProxy(editor, 'editor')
}
get DB (): IDBProxy {
return this._makeUserProxy(db)
}
}
export function setupPluginUserInstance (
pluginBaseInfo: LSPluginBaseInfo,
pluginCaller: LSPluginCaller
) {
return new LSPluginUser(pluginBaseInfo, pluginCaller)
}
if (window.__LSP__HOST__ == null) { // Entry of iframe mode
debug('Entry of iframe mode.')
const caller = new LSPluginCaller(null)
window.logseq = setupPluginUserInstance({} as any, caller)
}

279
libs/src/helpers.ts Normal file
View File

@@ -0,0 +1,279 @@
import { StyleString, UIOptions } from './LSPlugin'
import { PluginLocal } from './LSPlugin.core'
import { snakeCase } from 'snake-case'
interface IObject {
[key: string]: any;
}
declare global {
interface Window {
api: any
apis: any
}
}
export function isObject (item: any) {
return (item === Object(item) && !Array.isArray(item))
}
export function deepMerge (
target: IObject,
...sources: Array<IObject>
) {
// return the target if no sources passed
if (!sources.length) {
return target
}
const result: IObject = target
if (isObject(result)) {
const len: number = sources.length
for (let i = 0; i < len; i += 1) {
const elm: any = sources[i]
if (isObject(elm)) {
for (const key in elm) {
if (elm.hasOwnProperty(key)) {
if (isObject(elm[key])) {
if (!result[key] || !isObject(result[key])) {
result[key] = {}
}
deepMerge(result[key], elm[key])
} else {
if (Array.isArray(result[key]) && Array.isArray(elm[key])) {
// concatenate the two arrays and remove any duplicate primitive values
result[key] = Array.from(new Set(result[key].concat(elm[key])))
} else {
result[key] = elm[key]
}
}
}
}
}
}
}
return result
}
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) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
export function withFileProtocol (path: string) {
if (!path) return ''
const reg = /^(http|file|assets)/
if (!reg.test(path)) {
path = 'file://' + path
}
return path
}
/**
* @param timeout milliseconds
* @param tag string
*/
export function deferred<T = any> (timeout?: number, tag?: string) {
let resolve: any, reject: any
let settled = false
const timeFn = (r: Function) => {
return (v: T) => {
timeout && clearTimeout(timeout)
r(v)
settled = true
}
}
const promise = new Promise<T>((resolve1, reject1) => {
resolve = timeFn(resolve1)
reject = timeFn(reject1)
if (timeout) {
// @ts-ignore
timeout = setTimeout(() => reject(new Error(`[deferred timeout] ${tag}`)), timeout)
}
})
return {
created: Date.now(),
setTag: (t: string) => tag = t,
resolve, reject, promise,
get settled () {
return settled
}
}
}
export function invokeHostExportedApi (
method: string,
...args: Array<any>
) {
const method1 = snakeCase(method)
const fn = window.api[method1] || window.apis[method1] ||
window.api[method] || window.apis[method]
if (!fn) {
throw new Error(`Not existed method #${method}`)
}
return typeof fn !== 'function' ? fn : fn.apply(null, args)
}
export function setupIframeSandbox (
props: Record<string, any>,
target: HTMLElement
) {
const iframe = document.createElement('iframe')
iframe.classList.add('lsp-iframe-sandbox')
Object.entries(props).forEach(([k, v]) => {
iframe.setAttribute(k, v)
})
target.appendChild(iframe)
return async () => {
target.removeChild(iframe)
}
}
export function setupInjectedStyle (
style: StyleString,
attrs: Record<string, any>
) {
const key = attrs['data-injected-style']
let el = key && document.querySelector(`[data-injected-style=${key}]`)
if (el) {
el.textContent = style
return
}
el = document.createElement('style')
el.textContent = style
attrs && Object.entries(attrs).forEach(([k, v]) => {
el.setAttribute(k, v)
})
document.head.append(el)
return () => {
document.head.removeChild(el)
}
}
export function setupInjectedUI (
this: PluginLocal,
ui: UIOptions,
attrs: Record<string, any>
) {
const pl = this
const selector = ui.path || `#${ui.slot}`
const target = selector && document.querySelector(selector)
if (!target) {
console.error(`${this.debugTag} can not resolve selector target ${selector}`)
return
}
const key = `${ui.key}-${pl.id}`
let el = document.querySelector(`div[data-injected-ui="${key}"]`) as HTMLElement
if (el) {
el.innerHTML = ui.template
return
}
el = document.createElement('div')
el.dataset.injectedUi = key || ''
// TODO: Support more
el.innerHTML = ui.template
attrs && Object.entries(attrs).forEach(([k, v]) => {
el.setAttribute(k, v)
})
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)
})
return () => {
target!.removeChild(el)
}
}
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) => {
let v: any
switch (k) {
case FLAG_RECT:
if (!ds.hasOwnProperty(FLAG_RECT)) return
v = target.getBoundingClientRect().toJSON()
break
default:
v = target[k]
}
if (typeof v === 'object') {
v = { ...v }
}
obj[k] = v
})
}
return obj
}
let injectedThemeEffect: any = null
export function setupInjectedTheme (url?: string) {
injectedThemeEffect?.call()
if (!url) return
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = url
document.head.appendChild(link)
return (injectedThemeEffect = () => {
document.head.removeChild(link)
injectedThemeEffect = null
})
}

88
libs/tsconfig.json Normal file
View File

@@ -0,0 +1,88 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "ESNext",
/* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
"module": "ESNext",
/* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation. */
"allowJs": true,
/* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
"jsx": "react",
/* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
"declaration": true,
/* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
"sourceMap": false,
/* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "dist",
/* Redirect output structure to the directory. */
"rootDir": "src",
/* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true,
/* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
// "strict": true,
/* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
"strictNullChecks": true,
/* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
"strictPropertyInitialization": true,
/* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
"moduleResolution": "node",
/* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true,
/* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
/* Advanced Options */
"skipLibCheck": true,
/* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true
/* Disallow inconsistently-cased references to the same file. */
},
"include": [
"src/**/*.ts"
]
}

View File

@@ -0,0 +1,32 @@
const webpack = require('webpack')
const path = require('path')
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
module.exports = {
entry: './src/LSPlugin.core.ts',
devtool: 'inline-source-map',
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
plugins: [
new webpack.ProvidePlugin({
process: 'process/browser',
}),
// new BundleAnalyzerPlugin()
],
output: {
library: 'LSPlugin',
libraryTarget: 'umd',
filename: 'lsplugin.core.js',
path: path.resolve(__dirname, '../static/js'),
},
}

27
libs/webpack.config.js Normal file
View File

@@ -0,0 +1,27 @@
const path = require('path')
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
module.exports = {
entry: './src/LSPlugin.user.ts',
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
plugins: [
// new BundleAnalyzerPlugin()
],
output: {
library: "LSPluginEntry",
libraryTarget: "umd",
filename: 'lsplugin.user.js',
path: path.resolve(__dirname, 'dist')
},
}

1251
libs/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,137 @@
(ns ^:no-doc api
(:require [frontend.db :as db]
[frontend.db.model :as db-model]
[frontend.handler.block :as block-handler]
[frontend.util :as util]
[electron.ipc :as ipc]
[promesa.core :as p]
[camel-snake-kebab.core :as csk]
[cljs-bean.core :as bean]
[frontend.state :as state]
[frontend.components.plugins :as plugins]
[frontend.handler.plugin :as plugin-handler]
[frontend.handler.notification :as notification]
[datascript.core :as d]
[frontend.fs :as fs]
[clojure.string :as string]
[clojure.walk :as walk]
[cljs.reader]
[reitit.frontend.easy :as rfe]
[frontend.db.query-dsl :as query-dsl]))
;; base
(def ^:export show_themes
(fn []
(plugins/open-select-theme!)))
(def ^:export set_theme_mode
(fn [mode]
(state/set-theme! (if (= mode "light") "white" "dark"))))
(def ^:export load_plugin_config
(fn [path]
(fs/read-file "" (util/node-path.join path "package.json"))))
(def ^:export load_plugin_readme
(fn [path]
(fs/read-file "" (util/node-path.join path "readme.md"))))
(def ^:export save_plugin_config
(fn [path ^js data]
(let [repo ""
path (util/node-path.join path "package.json")]
(fs/write-file! repo "" path (js/JSON.stringify data nil 2) {:skip-mtime? true}))))
(def ^:export write_user_tmp_file
(fn [file content]
(p/let [repo ""
path (plugin-handler/get-ls-dotdir-root)
path (util/node-path.join path "tmp")
exist? (fs/file-exists? path "")
_ (when-not exist? (fs/mkdir! path))
path (util/node-path.join path file)
_ (fs/write-file! repo "" path content {:skip-mtime? true})]
path)))
(def ^:export load_user_preferences
(fn []
(p/let [repo ""
path (plugin-handler/get-ls-dotdir-root)
path (util/node-path.join path "preferences.json")
_ (fs/create-if-not-exists repo "" path)
json (fs/read-file "" path)
json (if (string/blank? json) "{}" json)]
(js/JSON.parse json))))
(def ^:export save_user_preferences
(fn [^js data]
(when data
(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-mtime? true})))))
(def ^:export load_plugin_user_settings
(fn [key]
(p/let [repo ""
path (plugin-handler/get-ls-dotdir-root)
exist? (fs/file-exists? path "settings")
_ (when-not exist? (fs/mkdir! (util/node-path.join path "settings")))
path (util/node-path.join path "settings" (str key ".json"))
_ (fs/create-if-not-exists repo "" path "{}")
json (fs/read-file "" path)]
[path (js/JSON.parse json)])))
(def ^:export save_plugin_user_settings
(fn [key ^js data]
(p/let [repo ""
path (plugin-handler/get-ls-dotdir-root)
path (util/node-path.join path "settings" (str key ".json"))]
(fs/write-file! repo "" path (js/JSON.stringify data nil 2) {:skip-mtime? true}))))
(def ^:export register_plugin_slash_command
(fn [pid ^js cmd-actions]
(when-let [[cmd actions] (bean/->clj cmd-actions)]
(plugin-handler/register-plugin-slash-command
pid [cmd (mapv #(into [(keyword (first %))]
(rest %)) actions)]))))
(def ^:export register_plugin_simple_command
(fn [pid ^js cmd-action]
(when-let [[cmd action] (bean/->clj cmd-action)]
(plugin-handler/register-plugin-simple-command
pid cmd (assoc action 0 (keyword (first action)))))))
;; app
(def ^:export push_state
(fn [^js k ^js params]
(rfe/push-state
(keyword k) (bean/->clj params))))
(def ^:export replace_state
(fn [^js k ^js params]
(rfe/replace-state
(keyword k) (bean/->clj params))))
;; editor
(def ^:export get_current_page_blocks_tree
(fn []
(when-let [page (state/get-current-page)]
(let [blocks (db-model/get-page-blocks-no-cache page)
blocks (mapv #(-> %
(dissoc :block/children)
(assoc :block/uuid (str (:block/uuid %))))
blocks)
blocks (block-handler/blocks->vec-tree blocks)
;; clean key
blocks (walk/postwalk
(fn [a]
(if (keyword? a)
(csk/->camelCase (name a))
a)) blocks)]
(bean/->js blocks)))))
;; db
(defn ^:export q
[query-string]
(when-let [repo (state/get-current-repo)]
@@ -21,3 +148,8 @@
(clj->js result)))))
(def ^:export custom_query db/custom-query)
;; helpers
(defn ^:export show_msg
([content] (show_msg content :success))
([content status] (notification/show! content (keyword status))))

View File

@@ -0,0 +1,154 @@
(ns frontend.components.plugins
(:require [rum.core :as rum]
[frontend.state :as state]
[cljs-bean.core :as bean]
[frontend.ui :as ui]
[frontend.util :as util]
[electron.ipc :as ipc]
[promesa.core :as p]
[frontend.components.svg :as svg]
[frontend.handler.notification :as notification]
[frontend.handler.plugin :as plugin-handler]))
(rum/defc installed-themes
< rum/reactive
[]
(let [themes (state/sub :plugin/installed-themes)
selected (state/sub :plugin/selected-theme)]
[:div.cp__themes-installed
[:h2.mb-4.text-xl "Installed Themes"]
(for [opt themes]
(let [current-selected (= selected (:url opt))]
[:div.it.flex.px-3.py-2.mb-2.rounded-sm.justify-between
{:key (:url opt)
:class [(if current-selected "selected")]
:on-click #(do (js/LSPluginCore.selectTheme (if current-selected nil (clj->js opt)))
(state/set-modal! nil))}
[:section
[:strong.block (:name opt)]
[:small.opacity-30 (:description opt)]]
[:small.flex-shrink-0.flex.items-center.opacity-10
(if current-selected "current")]]))]))
(rum/defc unpacked-plugin-loader
[unpacked-pkg-path]
(rum/use-effect!
(fn []
(let [err-handle
(fn [^js e]
(case (keyword (aget e "name"))
:IllegalPluginPackageError
(notification/show! "Illegal Logseq plugin package." :error)
:ExistedImportedPluginPackageError
(notification/show! "Existed Imported plugin package." :error)
:default)
(plugin-handler/reset-unpacked-state))
reg-handle #(plugin-handler/reset-unpacked-state)]
(when unpacked-pkg-path
(doto js/LSPluginCore
(.once "error" err-handle)
(.once "registered" reg-handle)
(.register (bean/->js {:url unpacked-pkg-path}))))
#(doto js/LSPluginCore
(.off "error" err-handle)
(.off "registered" reg-handle))))
[unpacked-pkg-path])
(when unpacked-pkg-path
[:strong.inline-flex.px-3 "Loading ..."]))
(rum/defc simple-markdown-display
< rum/reactive
[]
(let [content (state/sub :plugin/active-readme)]
[:textarea.p-1.bg-transparent.border-none
{:style {:width "700px" :min-height "60vw"}}
content]))
(rum/defc plugin-item-card
[{:keys [id name settings version url description author icon usf] :as item}]
(let [disabled (:disabled settings)]
[:div.cp__plugins-item-card
[:div.l.link-block
{:on-click #(plugin-handler/open-readme! url simple-markdown-display)}
(if icon
[:img.icon {:src icon}]
svg/folder)]
[:div.r
[:h3.head.text-xl.font-bold.pt-1.5
{:on-click #(plugin-handler/open-readme! url simple-markdown-display)}
[:span name]
[:sup.inline-block.px-1.text-xs.opacity-30 version]]
[:div.desc.text-xs.opacity-60
[:p description]
[:small (js/JSON.stringify (bean/->js settings))]]
[:div.flag
[:p.text-xs.text-gray-300.pr-2.flex.justify-between.dark:opacity-40
[:small author]
[:small (str "ID: " id)]]]
[:div.ctl
[:div.l
[:div.de
[:strong svg/settings-sm]
[:ul.menu-list
[:li {:on-click #(if usf (js/apis.openPath usf))} "Open settings"]
[:li {:on-click
#(let [confirm-fn
(ui/make-confirm-modal
{:title (str "Are you sure uninstall plugin - " name "?")
:on-confirm (fn [_ {:keys [close-fn]}]
(close-fn)
(plugin-handler/unregister-plugin id))})]
(state/set-modal! confirm-fn))}
"Uninstall plugin"]]]]
[:div.flex.items-center
[:small.de (if disabled "Disabled" "Enabled")]
(ui/toggle (not disabled)
(fn []
(js-invoke js/LSPluginCore (if disabled "enable" "disable") id))
true)]]]]))
(rum/defc installed-page
< rum/reactive
[]
(let [installed-plugins (state/sub :plugin/installed-plugins)
selected-unpacked-pkg (state/sub :plugin/selected-unpacked-pkg)]
[:div.cp__plugins-page-installed
[:h1 "Installed Plugins"]
[:div.mb-6.flex.items-center.justify-between
(ui/button
"Load unpacked plugin"
:intent "logseq"
:on-click plugin-handler/load-unpacked-plugin)
(unpacked-plugin-loader selected-unpacked-pkg)
(when (util/electron?)
(ui/button
[:span.flex.items-center
;;svg/settings-sm
"Open plugin preferences file"]
:intent "logseq"
:on-click (fn []
(p/let [root (plugin-handler/get-ls-dotdir-root)]
(js/apis.openPath (str root "/preferences.json"))))))]
[:div.cp__plugins-item-lists.grid-cols-1.md:grid-cols-2.lg:grid-cols-3
(for [[_ item] installed-plugins]
(rum/with-key (plugin-item-card item) (:id item)))]]))
(defn open-select-theme!
[]
(state/set-modal! installed-themes))
(rum/defc hook-ui-slot
([type payload] (hook-ui-slot type payload nil))
([type payload opts]
(let [id (str "slot__" (util/rand-str 8))]
(rum/use-effect!
(fn []
(plugin-handler/hook-plugin-app type {:slot id :payload payload} nil)
#())
[])
[:div.lsp-hook-ui-slot
(merge opts {:id id})])))

View File

@@ -0,0 +1,185 @@
.cp__plugins {
&-page-installed {
min-height: 60vh;
padding-top: 20px;
> h1 {
padding: 20px 0;
font-size: 38px;
}
}
&-item-lists {
@apply w-full grid grid-flow-row gap-3 pt-1;
}
&-item-card {
@apply flex py-3 px-1 rounded-md;
background-color: var(--ls-secondary-background-color);
height: 180px;
svg, .icon {
width: 70px;
height: 70px;
opacity: .8;
&:hover {
opacity: 1;
}
}
.head {
max-height: 60px;
overflow: hidden;
cursor: pointer;
&:active {
opacity: .8;
}
}
.desc {
height: 60px;
overflow: hidden;
}
.flag {
position: absolute;
bottom: 20px;
left: 0;
width: 100%;
}
> .l {
padding: 8px;
}
> .r {
flex: 1;
position: relative;
p {
@apply py-1 m-0;
}
.ctl {
@apply flex pl-2 items-center justify-between absolute w-full;
bottom: -8px;
right: 8px;
.de {
font-size: 10px;
padding: 5px 0;
padding-right: 10px;
border-radius: 2px;
user-select: none;
transition: none;
opacity: .2;
position: relative;
.menu-list {
@apply shadow-md rounded-sm absolute hidden list-none overflow-hidden m-0 p-0;
background-color: var(--ls-primary-background-color);
top: 20px;
left: 0;
min-width: 100px;
> li {
margin: 0;
padding: 3px;
transition: background-color .2s;
user-select: none;
opacity: .8;
&:hover {
background-color: var(--ls-quaternary-background-color);
&:active {
opacity: 1;
}
}
}
}
&.err {
@apply text-red-500 opacity-100;
}
&.log {
padding: 5px;
}
svg {
width: 13px;
height: 13px;
}
}
> .l {
@apply flex items-center;
margin-left: -80px;
.de {
&:hover {
opacity: .9;
.menu-list {
display: block;
}
}
}
}
}
}
}
}
.cp__themes {
&-installed {
min-width: 480px;
> .it {
user-select: none;
cursor: pointer;
background-color: var(--ls-secondary-background-color);
border: 1px solid transparent;
transition: background-color .3s;
&:hover, &.selected {
background-color: var(--ls-quaternary-background-color);
}
}
}
}
.lsp-iframe-sandbox, .lsp-shadow-sandbox {
position: absolute;
top: 0;
left: 0;
z-index: -1;
visibility: hidden;
height: 0;
width: 0;
padding: 0;
margin: 0;
&.visible {
z-index: 1;
width: 100vw;
height: 100vh;
visibility: visible;
}
}
body {
&[data-page=page] {
.lsp-hook-ui-slot {
@apply flex items-center px-1 opacity-70;
}
}
}

View File

@@ -0,0 +1,181 @@
(ns frontend.handler.plugin
(:require [promesa.core :as p]
[rum.core :as rum]
[frontend.util :as util]
[frontend.fs :as fs]
[frontend.handler.notification :as notifications]
[camel-snake-kebab.core :as csk]
[frontend.state :as state]
[medley.core :as md]
[electron.ipc :as ipc]
[cljs-bean.core :as bean]
[clojure.string :as string]))
(defonce lsp-enabled? (util/electron?))
;; state handlers
(defn register-plugin
[pl]
(swap! state/state update-in [:plugin/installed-plugins] assoc (keyword (:id pl)) pl))
(defn unregister-plugin
[id]
(js/LSPluginCore.unregister id))
(defn host-mounted!
[]
(and lsp-enabled? (js/LSPluginCore.hostMounted)))
(defn register-plugin-slash-command
[pid [cmd actions]]
(prn (if-let [pid (keyword pid)]
(when (contains? (:plugin/installed-plugins @state/state) pid)
(do (swap! state/state update-in [:plugin/installed-commands pid]
(fnil merge {}) (hash-map cmd (mapv #(conj % {:pid pid}) actions)))
true)))))
(defn unregister-plugin-slash-command
[pid]
(swap! state/state md/dissoc-in [:plugin/installed-commands (keyword pid)]))
(defn register-plugin-simple-command
;; action => [:action-key :event-key]
[pid {:keys [key label type] :as cmd} action]
(if-let [pid (keyword pid)]
(when (contains? (:plugin/installed-plugins @state/state) pid)
(do (swap! state/state update-in [:plugin/simple-commands pid]
(fnil conj []) [type cmd action pid])
true))))
(defn unregister-plugin-simple-command
[pid]
(swap! state/state md/dissoc-in [:plugin/simple-commands (keyword pid)]))
(defn update-plugin-settings
[id settings]
(swap! state/state update-in [:plugin/installed-plugins id] assoc :settings settings))
(defn open-readme!
[url display]
(when url
(-> (p/let [content (js/api.load_plugin_readme url)]
(state/set-state! :plugin/active-readme content)
(state/set-modal! display))
(p/catch #(notifications/show! "No README file." :warn)))))
(defn load-unpacked-plugin
[]
(if util/electron?
(p/let [path (ipc/ipc "openDialogSync")]
(when-not (:plugin/selected-unpacked-pkg @state/state)
(state/set-state! :plugin/selected-unpacked-pkg path)))))
(defn reset-unpacked-state
[]
(state/set-state! :plugin/selected-unpacked-pkg nil))
(defn hook-plugin
[tag type payload plugin-id]
(when lsp-enabled?
(js-invoke js/LSPluginCore
(str "hook" (string/capitalize (name tag)))
(name type)
(if (map? payload)
(bean/->js (into {} (for [[k v] payload] [(csk/->camelCase k) (if (uuid? v) (str v) v)]))))
(if (keyword? plugin-id) (name plugin-id) plugin-id))))
(defn hook-plugin-app
([type payload] (hook-plugin-app type payload nil))
([type payload plugin-id] (hook-plugin :app type payload plugin-id)))
(defn hook-plugin-editor
([type payload] (hook-plugin-editor type payload nil))
([type payload plugin-id] (hook-plugin :editor type payload plugin-id)))
(defn get-ls-dotdir-root
[]
(ipc/ipc "getLogseqDotDirRoot"))
(defn- get-user-default-plugins
[]
(p/catch
(p/let [files ^js (ipc/ipc "getUserDefaultPlugins")
files (js->clj files)]
(map #(hash-map :url %) files))
(fn [e]
(js/console.error e))))
;; components
(rum/defc lsp-indicator < rum/reactive
[]
(let [text (state/sub :plugin/indicator-text)]
(if (= text "END")
[:span]
[:div
{:style
{:width "100%"
:height "100vh"
:display "flex"
:align-items "center"
:justify-content "center"}}
[:span
{:style
{:color "#aaa"
:font-size "38px"}} (or text "Loading ...")]])))
(defn init-plugins
[callback]
(let [el (js/document.createElement "div")]
(.appendChild js/document.body el)
(rum/mount
(lsp-indicator) el))
(state/set-state! :plugin/indicator-text "Loading...")
(p/then
(p/let [root (get-ls-dotdir-root)
_ (.setupPluginCore js/LSPlugin (bean/->js {:localUserConfigRoot root}))
_ (doto js/LSPluginCore
(.on "registered"
(fn [^js pl]
(register-plugin
(bean/->clj (.parse js/JSON (.stringify js/JSON pl))))))
(.on "unregistered" (fn [pid]
(let [pid (keyword pid)]
;; plugins
(swap! state/state md/dissoc-in [:plugin/installed-plugins (keyword pid)])
;; commands
(unregister-plugin-slash-command pid))))
(.on "theme-changed" (fn [^js themes]
(swap! state/state assoc :plugin/installed-themes
(vec (mapcat (fn [[_ vs]] (bean/->clj vs)) (bean/->clj themes))))))
(.on "theme-selected" (fn [^js opts]
(let [opts (bean/->clj opts)
url (:url opts)
mode (:mode opts)]
(when mode (state/set-theme! mode))
(state/set-state! :plugin/selected-theme url))))
(.on "settings-changed" (fn [id ^js settings]
(let [id (keyword id)]
(when (and settings
(contains? (:plugin/installed-plugins @state/state) id))
(update-plugin-settings id (bean/->clj settings)))))))
default-plugins (get-user-default-plugins)
_ (.register js/LSPluginCore (bean/->js (if (seq default-plugins) default-plugins [])) true)])
#(do
(state/set-state! :plugin/indicator-text "END")
(callback))))
(defn setup!
"setup plugin core handler"
[callback]
(if (not lsp-enabled?)
(callback)
(init-plugins callback)))