Browse Source

Feat/plugin marketplace (#2766)

* Squashed commit of the following:

commit ea9af272e40761d0e56f100c4c7db7bc4953ba66
Author: Tienson Qin <[email protected]>
Date:   Fri Aug 27 00:23:24 2021 +0800

    feat: type c to git commit

    also, fixed an issue that backspace can delete selected blocks
    when there's a dialog.

commit 78e24f747957ec380fb4a6c31cac6eb3c8caa9d5
Author: Tienson Qin <[email protected]>
Date:   Fri Aug 27 00:01:21 2021 +0800

    fix: add patch parser worker to yarn watch

commit 7f6e777bcd94a3a2384d61ecd88b9a7a6f14ea26
Author: Tienson Qin <[email protected]>
Date:   Thu Aug 26 23:57:46 2021 +0800

    fix: add several shortcuts to the Others category

commit 509697b276b51a68480bbd975ac4d0c7010729da
Author: Tienson Qin <[email protected]>
Date:   Thu Aug 26 23:55:40 2021 +0800

    fix: git username and email configuration

* Squashed commit of the following:

commit 401d85be5f50ca6913ef20f6321bdb8359867619
Author: Peng Xiao <[email protected]>
Date:   Fri Aug 27 11:10:32 2021 +0800

    feat: add protobuf mode

commit dc1e9fdfc99df27735b19c7d2306375712940bba
Author: Tienson Qin <[email protected]>
Date:   Fri Aug 27 13:33:47 2021 +0800

    chore: replace : with comma for git path

    ':' is a reserved character on Windows

commit ea9af272e40761d0e56f100c4c7db7bc4953ba66
Author: Tienson Qin <[email protected]>
Date:   Fri Aug 27 00:23:24 2021 +0800

    feat: type c to git commit

    also, fixed an issue that backspace can delete selected blocks
    when there's a dialog.

commit 78e24f747957ec380fb4a6c31cac6eb3c8caa9d5
Author: Tienson Qin <[email protected]>
Date:   Fri Aug 27 00:01:21 2021 +0800

    fix: add patch parser worker to yarn watch

commit 7f6e777bcd94a3a2384d61ecd88b9a7a6f14ea26
Author: Tienson Qin <[email protected]>
Date:   Thu Aug 26 23:57:46 2021 +0800

    fix: add several shortcuts to the Others category

commit 509697b276b51a68480bbd975ac4d0c7010729da
Author: Tienson Qin <[email protected]>
Date:   Thu Aug 26 23:55:40 2021 +0800

    fix: git username and email configuration

* feat(plugin): ui of marketplace plugins list

* improve(plugin): support reload

* improve(plugin): installation from marketplace

* fix conflicts

* improve(plugin): installation from github public repo

* chore: remove unwanted dependency

* chore: remove console

* improve(plugin): add shortcuts

* ui(plugin): polish LOADING indicator

* improve(plugin): support up-to-date of marketplace plugin

* fix: remove debug option

* improve(plugin): better interaction of themes picker

* improve(plugin): better experience when installing theme from marketplace

* fix(plugin): downloads label of marketplace plugin

* improve(plugin): update package name field

* improve(plugin): change marketplace packages repo to `logseq/marketplace`

* fix(plugin): plugin title when updating notification

* fix: conflicts

* enhance(plugin): i18n related marketplace & lifecycle of plugin installation

* improve(plugin): handle offline situation

* ui(plugin): header plugin icons container

* fix(ui): add class identity for journal page with date page name

* improve(plugin): remote readme for marketplace plugin

* enhance(plugin): polish plugin card

* chore(plugin): build libs core

* Squashed commit of the following:

commit 751db4828c85a79f78175d618d2cad46631003dd
Author: Tienson Qin <[email protected]>
Date:   Tue Sep 7 16:58:25 2021 +0800

    enhance: log git errors

commit c2dbbc77bff30a79fae5e4cf9f2e185d64788f7d
Author: Tienson Qin <[email protected]>
Date:   Tue Sep 7 16:27:00 2021 +0800

    enhance: display refresh status

commit f734b6db37d67954192a3377e40c543835ccd736
Author: Tienson Qin <[email protected]>
Date:   Tue Sep 7 16:03:57 2021 +0800

    fix: .git doesn't work well with third-party cloud services

commit 7e44d81f1df0cd84c50169aaf1a308c38a7af0c2
Author: Tienson Qin <[email protected]>
Date:   Tue Sep 7 13:52:38 2021 +0800

    fix: git init into the current graph folder

    instead of a separate git directory because .gitdir might has
    different paths on multiple devices, another reason is that the graph
    might have different histories considering the .git directory is not
    synced.

commit b86a8015143f840488e588f3f8f3b2a92ea4bac7
Author: Tienson Qin <[email protected]>
Date:   Mon Sep 6 23:23:27 2021 +0800

    enhance: don't show diff if there's only blank changes

commit 0b55d119aa938e82681701e8c991719b47c71ab4
Author: Tienson Qin <[email protected]>
Date:   Mon Sep 6 22:39:06 2021 +0800

    fix: save the previous content in Logseq first and commit it

    to avoid overwritten when syncing with iCloud/Dropbox/syncthing.

commit e0baf4b05c6dcf02d2a75f03b2ebf128b69f4e21
Author: Tienson Qin <[email protected]>
Date:   Mon Sep 6 22:18:45 2021 +0800

    fix: close file watcher when exit the app

commit 10e7a9fbd687daf70f1bf0f851513cd489ab57da
Author: Tienson Qin <[email protected]>
Date:   Mon Sep 6 21:05:38 2021 +0800

    fix: disable cut selections in the query result block

commit 90c2bd7cc2098d238d550934e2d083ddc893e553
Author: Tienson Qin <[email protected]>
Date:   Mon Sep 6 18:42:17 2021 +0800

    fix: terminate parser and persist dbs when reloading the app (electron)

commit 571c81af307fd0a28596a425965524485ce783cc
Author: Tienson Qin <[email protected]>
Date:   Mon Sep 6 17:57:33 2021 +0800

    enhance: add sync from local files (the old refresh way)

commit a16e5c98ba09c1d871831ba831119f99b123a6b7
Author: Tienson Qin <[email protected]>
Date:   Mon Sep 6 17:23:57 2021 +0800

    fix: Dragging blocks to update notes does not synchronize updates to
    the notes file in real time.

    close #2744

commit 6897a22a3f28c50c7f0840cbd51a80e00f6a55a2
Author: Tienson Qin <[email protected]>
Date:   Mon Sep 6 16:43:30 2021 +0800

    fix: disable page/block auto-complete once the cursor went outside

commit feb4404874f811a4c8d2c206d7e15fa0236d7ac8
Author: Tienson Qin <[email protected]>
Date:   Mon Sep 6 13:28:47 2021 +0800

    fix: wrong page metadata saved for another graph

commit b96332122f393801bacb4ee27e6925e933ad8cfe
Author: Tienson Qin <[email protected]>
Date:   Mon Sep 6 13:08:28 2021 +0800

    enhance: catch errors when app quits

commit 1ee0c240c37cd4fb11e55acd3d98901ef9b0ab2c
Author: Jiang Hailong <[email protected]>
Date:   Wed Sep 1 20:54:13 2021 +0800

    FIX: Linked reference is not refresh after file altering #2694

commit 0550c8a8761fb2b2091cbfc8f805cebe7a414c73
Author: Tienson Qin <[email protected]>
Date:   Mon Sep 6 12:25:39 2021 +0800

    fix: display logbook for scheduled tasks

commit 2a5f0cee7c4ab87c2263cde5f67637634d34bc52
Author: Tienson Qin <[email protected]>
Date:   Mon Sep 6 12:21:19 2021 +0800

    fix: spent hours for logbook

commit 1f2c9e4d3f9c787743a10319b9b9e7e89475d644
Author: leizhe <[email protected]>
Date:   Sat Sep 4 12:59:09 2021 +0900

    fix(timestamp): remove old SCHEDULED/DEADLINE timestamp

    When using `date-picker` to update the SCHEDULED/DEADLINE timestamp by
    clicking an existing one, logseq will add a new timestamp instead of
    updating the old one. This patch fixs this issue.

commit cb23b967e403bffc0a4546b916b03cd766f91ff8
Author: leizhe <[email protected]>
Date:   Sat Sep 4 11:36:34 2021 +0900

    fix(repeat): more consistent with orgmode style

    Ref: https://orgmode.org/manual/Tracking-your-habits.html

commit 229c7f2594df6bb9bd9a68c7b4c4a68cb16bf7e7
Author: leizhe <[email protected]>
Date:   Sat Sep 4 15:11:07 2021 +0900

    enhance(property): remove empty properties drawer

commit a76df9ce972eb1afd8e03061414d31f3ab893f08
Author: leizhe <[email protected]>
Date:   Sat Sep 4 10:27:30 2021 +0900

    fix(clock): duplicate clock-in log twice

    1. `set-marker` will not log time anymore. The time logging is moved
    to `with-timetracking`
    2. Concat `logbook` only if `new-clocks` is nil, which fixs the
    duplication of clock-in log.

commit c79e9f9e3eb394924a5f54fcbef78495bcb825de
Author: Tienson Qin <[email protected]>
Date:   Mon Sep 6 12:04:16 2021 +0800

    code: cleanup

commit 9ec85db09acb40856cfe8f6eac59095a060e2e23
Author: DarshanSudhakar <$K3Ug1i&>
Date:   Mon Sep 6 08:32:07 2021 +0530

    Fixing typo  for the tooltip 'Block reference'

commit eec677873b29203a234ff17d5c1e0db8a920a59c
Author: Tienson Qin <[email protected]>
Date:   Mon Sep 6 11:49:58 2021 +0800

    enhance: page history support reverting back

commit 7273112a00f7189f253b7078b888a9bb14db161b
Author: Tienson Qin <[email protected]>
Date:   Mon Sep 6 11:23:31 2021 +0800

    git: revert back

commit cd853b58647539b5ed930e53f3cf4f15f8d030c5
Author: Tienson Qin <[email protected]>
Date:   Mon Sep 6 10:39:08 2021 +0800

    git: should compare ignored-files with disk content

commit a84dfb5eff307275fb152e8ab8afc6df2f1a424b
Author: Tienson Qin <[email protected]>
Date:   Mon Sep 6 10:31:16 2021 +0800

    git: add ignore-files to avoid repeated notifications

commit 86577e7ebf9b0be72656fcf88df00cc30e7d0557
Author: Tienson Qin <[email protected]>
Date:   Mon Sep 6 10:07:39 2021 +0800

    fix: ignore permission denied error when git add all

commit 8dc0ca9ff522511a80537492aeffef6a91e7a2d6
Author: Tienson Qin <[email protected]>
Date:   Mon Sep 6 09:54:03 2021 +0800

    fix: run git config core.safecrlf false on windows

commit 9edaae559d8e24f3ea0606fb828036a3f1f2678a
Author: Tienson Qin <[email protected]>
Date:   Mon Sep 6 09:20:06 2021 +0800

    refactor: extract file ops

commit f12f58d3fa14c081030cdac6c4b812d580ac7208
Author: tiagodevezas <[email protected]>
Date:   Fri Sep 3 23:25:07 2021 +0100

    fix typos

commit 9e82f0117c63d7553676e633b382c0ef9b698081
Author: tiagodevezas <[email protected]>
Date:   Fri Sep 3 21:32:03 2021 +0100

    Translate shortcuts to Portuguese (pt-PT)

commit 9a2c17bb05b98da52a9dd6d15657b00312f093c9
Author: tiagodevezas <[email protected]>
Date:   Fri Sep 3 20:58:33 2021 +0100

    Translate new settings to pt-PT

commit c05034cc34d5316f5581e703740d431c2a7476a2
Author: Sebastian Bensusan <[email protected]>
Date:   Sat Sep 4 07:57:07 2021 -0700

    feat(calc): Understand percentages

commit bfe6a5d6cb957739fdec2e1fd7e15333ddcca907
Author: Tienson Qin <[email protected]>
Date:   Mon Sep 6 08:46:30 2021 +0800

    enhance: commit the content in logseq when detecting any disk changes

* improve(plugin): add install plugin api

* fix(plugin): protected plugin installation api

* improve(plugin): tweak readme display for local

* fix: conflicts

* fix(plugin): get block option with include children

* improve(plugin): copy more marketplace manifest fields to plugin

* fix: conflicts

* improve(plugin): shortcut for copying plugin id

* Squashed commit of the following:

commit e51ea54a75752db751e6c4f36f99ca0b87268c08
Author: Tienson Qin <[email protected]>
Date:   Mon Sep 13 10:40:25 2021 +0800

    fix: change ack timeout

* fix(plugin): non blank icon string

* fix: conflicts

* fix: e name

* fix: remove debug from state

* chore(plugin): bump libs minor version

Co-authored-by: Tienson Qin <[email protected]>
Charlie 4 years ago
parent
commit
18b7a7864b
36 changed files with 1407 additions and 378 deletions
  1. 1 1
      libs/package.json
  2. 6 2
      libs/src/LSPlugin.caller.ts
  3. 61 22
      libs/src/LSPlugin.core.ts
  4. 1 0
      libs/src/LSPlugin.ts
  5. 6 1
      libs/src/helpers.ts
  6. 3 1
      libs/src/postmate/index.ts
  7. 0 1
      resources/js/lsplugin.core.js
  8. 5 0
      resources/js/marked.min.js
  9. 27 11
      resources/js/preload.js
  10. 108 0
      resources/marketplace.html
  11. 2 1
      resources/package.json
  12. 1 0
      src/electron/electron/configs.cljs
  13. 6 4
      src/electron/electron/core.cljs
  14. 35 44
      src/electron/electron/handler.cljs
  15. 177 0
      src/electron/electron/plugin.cljs
  16. 1 1
      src/electron/electron/updater.cljs
  17. 23 2
      src/electron/electron/utils.cljs
  18. 1 1
      src/main/frontend/components/command_palette.cljs
  19. 7 4
      src/main/frontend/components/header.cljs
  20. 11 5
      src/main/frontend/components/page.cljs
  21. 354 134
      src/main/frontend/components/plugins.cljs
  22. 174 37
      src/main/frontend/components/plugins.css
  23. 3 4
      src/main/frontend/components/sidebar.cljs
  24. 50 2
      src/main/frontend/components/svg.cljs
  25. 12 7
      src/main/frontend/components/theme.cljs
  26. 32 0
      src/main/frontend/dicts.cljs
  27. 4 0
      src/main/frontend/handler/events.cljs
  28. 249 81
      src/main/frontend/handler/plugin.cljs
  29. 2 2
      src/main/frontend/mixins.cljs
  30. 9 0
      src/main/frontend/modules/shortcut/config.cljs
  31. 1 1
      src/main/frontend/page.cljs
  32. 1 1
      src/main/frontend/routes.cljs
  33. 3 0
      src/main/frontend/state.cljs
  34. 2 1
      src/main/frontend/util/pool.cljs
  35. 20 2
      src/main/logseq/api.cljs
  36. 9 5
      tailwind.config.js

+ 1 - 1
libs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@logseq/libs",
-  "version": "0.0.1-alpha.27",
+  "version": "0.0.1-alpha.29",
   "description": "Logseq SDK libraries",
   "main": "dist/lsplugin.user.js",
   "typings": "index.d.ts",

+ 6 - 2
libs/src/LSPlugin.caller.ts

@@ -190,13 +190,18 @@ class LSPluginCaller extends EventEmitter {
   async _setupIframeSandbox () {
     const cnt = document.body
     const pl = this._pluginLocal!
+    const id = pl.id
     const url = new URL(pl.options.entry!)
 
     url.searchParams
       .set(`__v__`, IS_DEV ? Date.now().toString() : pl.options.version)
 
+    // clear zombie sandbox
+    const zb = cnt.querySelector(`#${id}`)
+    if (zb) zb.parentElement.removeChild(zb)
+
     const pt = new Postmate({
-      container: cnt, url: url.href,
+      id, container: cnt, url: url.href,
       classListArray: ['lsp-iframe-sandbox'],
       model: { baseInfo: JSON.parse(JSON.stringify(pl.toJSON())) }
     })
@@ -217,7 +222,6 @@ class LSPluginCaller extends EventEmitter {
         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)
 

+ 61 - 22
libs/src/LSPlugin.core.ts

@@ -144,6 +144,7 @@ type PluginLocalOptions = {
   mode: 'shadow' | 'iframe'
   settings?: PluginSettings
   logger?: PluginLogger
+  effect?: boolean
 
   [key: string]: any
 }
@@ -357,11 +358,10 @@ class PluginLocal
 
   async _setupUserSettings () {
     const { _options } = this
-    const key = _options.name.replace(/[^a-z0-9]/gi, '_').toLowerCase() + '_' + this.id
     const logger = _options.logger = new PluginLogger('Loader')
 
     try {
-      const [userSettingsFilePath, userSettings] = await invokeHostExportedApi('load_plugin_user_settings', key)
+      const [userSettingsFilePath, userSettings] = await invokeHostExportedApi('load_plugin_user_settings', this.id)
       this._dotSettingsFile = userSettingsFilePath
 
       const settings = _options.settings = new PluginSettings(userSettings)
@@ -381,7 +381,7 @@ class PluginLocal
         }
 
         if (a) {
-          invokeHostExportedApi(`save_plugin_user_settings`, key, a)
+          invokeHostExportedApi(`save_plugin_user_settings`, this.id, a)
         }
       })
     } catch (e) {
@@ -399,13 +399,14 @@ class PluginLocal
   }
 
   _resolveResourceFullUrl (filePath: string, localRoot?: string) {
+    if (!filePath?.trim()) return
     localRoot = localRoot || this._localRoot
     const reg = /^(http|file)/
     if (!reg.test(filePath)) {
       const url = path.join(localRoot, filePath)
       filePath = reg.test(url) ? url : (PROTOCOL_FILE + url)
     }
-    return this.isInstalledInDotRoot ?
+    return (!this.options.effect && this.isInstalledInDotRoot) ?
       convertToLSPResource(filePath, this.dotPluginsRoot) : filePath
   }
 
@@ -430,7 +431,9 @@ class PluginLocal
     }
 
     // Pick legal attrs
-    ['name', 'author', 'repository', 'version', 'description'].forEach(k => {
+    ['name', 'author', 'repository', 'version',
+      'description', 'repo', 'title', 'effect'
+    ].forEach(k => {
       this._options[k] = pkg[k]
     })
 
@@ -447,21 +450,26 @@ class PluginLocal
       }
     }
 
+    const title = logseq.title || pkg.title
     const icon = logseq.icon || pkg.icon
 
-    if (icon) {
-      this._options.icon = this._resolveResourceFullUrl(icon)
-    }
+    this._options.title = title
+    this._options.icon = icon &&
+      this._resolveResourceFullUrl(icon)
 
     // TODO: strategy for Logseq plugins center
-    if (logseq.id) {
-      this._id = logseq.id
+    if (this.isInstalledInDotRoot) {
+      this._id = path.basename(localRoot)
     } else {
-      logseq.id = this.id
-      try {
-        await invokeHostExportedApi('save_plugin_config', url, { ...pkg, logseq })
-      } catch (e) {
-        debug('[save plugin ID Error] ', e)
+      if (logseq.id) {
+        this._id = logseq.id
+      } else {
+        logseq.id = this.id
+        try {
+          await invokeHostExportedApi('save_plugin_config', url, { ...pkg, logseq })
+        } catch (e) {
+          debug('[save plugin ID Error] ', e)
+        }
       }
     }
 
@@ -609,7 +617,13 @@ class PluginLocal
   }
 
   async reload () {
-    debug('TODO: reload plugin', this.id)
+    if (this.pending) {
+      return
+    }
+
+    await this.unload()
+    await this.load()
+    this._ctx.emit('reloaded', this)
   }
 
   /**
@@ -624,7 +638,7 @@ class PluginLocal
       await this.unload()
 
       if (this.isInstalledInDotRoot) {
-        debug('TODO: remove plugin local files from user home root :)')
+        this._ctx.emit('unlink-plugin', this.id)
       }
 
       return
@@ -648,6 +662,7 @@ class PluginLocal
       this.emit('unloaded')
     } catch (e) {
       debug('[plugin unload Error]', e)
+      return false
     } finally {
       this._status = PluginLocalLoadStatus.UNLOADED
     }
@@ -773,7 +788,7 @@ class PluginLocal
  */
 class LSPluginCore
   extends EventEmitter<'beforeenable' | 'enabled' | 'beforedisable' | 'disabled' | 'registered' | 'error' | 'unregistered' |
-    'theme-changed' | 'theme-selected' | 'settings-changed'>
+    'theme-changed' | 'theme-selected' | 'settings-changed' | 'unlink-plugin' | 'reloaded'>
   implements ILSPluginThemeManager {
 
   private _isRegistering = false
@@ -782,6 +797,7 @@ class LSPluginCore
   private _userPreferences: Partial<UserPreferences> = {}
   private _registeredThemes = new Map<PluginLocalIdentity, Array<ThemeOptions>>()
   private _registeredPlugins = new Map<PluginLocalIdentity, PluginLocal>()
+  private _currentTheme: { dis: () => void, pid: PluginLocalIdentity, opt: ThemeOptions }
 
   /**
    * @param _options
@@ -933,8 +949,12 @@ class LSPluginCore
     }
 
     for (const identity of plugins) {
-      const p = this.ensurePlugin(identity)
-      await p.reload()
+      try {
+        const p = this.ensurePlugin(identity)
+        await p.reload()
+      } catch (e) {
+        debug(e)
+      }
     }
   }
 
@@ -1065,9 +1085,22 @@ class LSPluginCore
   }
 
   async selectTheme (opt?: ThemeOptions, effect = true): Promise<void> {
-    setupInjectedTheme(opt?.url)
+    // clear current
+    if (this._currentTheme) {
+      this._currentTheme.dis?.()
+    }
+
+    const disInjectedTheme = setupInjectedTheme(opt?.url)
     this.emit('theme-selected', opt)
-    effect && this.saveUserPreferences({ theme: opt })
+    effect && await this.saveUserPreferences({ theme: opt?.url ? opt : null })
+    if (opt?.url) {
+      this._currentTheme = {
+        dis: () => {
+          disInjectedTheme()
+          effect && this.saveUserPreferences({ theme: null })
+        }, opt, pid: opt.pid
+      }
+    }
   }
 
   async unregisterTheme (id: PluginLocalIdentity): Promise<void> {
@@ -1076,6 +1109,12 @@ class LSPluginCore
     if (!this._registeredThemes.has(id)) return
     this._registeredThemes.delete(id)
     this.emit('theme-changed', this.themes, { id })
+    if (this._currentTheme?.pid == id) {
+      this._currentTheme.dis?.()
+      this._currentTheme = null
+      // reset current theme
+      this.emit('theme-selected', null)
+    }
   }
 }
 

+ 1 - 0
libs/src/LSPlugin.ts

@@ -55,6 +55,7 @@ export type UIOptions = UIPathOptions | UISlotOptions
 
 export interface LSPluginPkgConfig {
   id: PluginLocalIdentity
+  title: string
   mode: 'shadow' | 'iframe'
   themes: Array<ThemeOptions>
   icon: string

+ 6 - 1
libs/src/helpers.ts

@@ -168,6 +168,7 @@ export function invokeHostExportedApi (
   method: string,
   ...args: Array<any>
 ) {
+  method = method?.replace(/^[_$]+/, '')
   const method1 = snakeCase(method)
 
   // @ts-ignore
@@ -335,7 +336,11 @@ export function setupInjectedTheme (url?: string) {
   document.head.appendChild(link)
 
   return (injectedThemeEffect = () => {
-    document.head.removeChild(link)
+    try {
+      document.head.removeChild(link)
+    } catch (e) {
+      console.error(e)
+    }
     injectedThemeEffect = null
   })
 }

+ 3 - 1
libs/src/postmate/index.ts

@@ -254,6 +254,7 @@ export class ChildAPI {
 export type PostMateOptions = {
   container: HTMLElement
   url: string
+  id?: string
   classListArray?: Array<string>
   name?: string
   model?: any
@@ -281,7 +282,8 @@ export class Postmate {
     this.url = opts.url
     this.parent = window
     this.frame = document.createElement('iframe')
-    this.frame.name = opts.name || ''
+    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.container.appendChild(this.frame)
     this.child = this.frame.contentWindow

File diff suppressed because it is too large
+ 0 - 1
resources/js/lsplugin.core.js


File diff suppressed because it is too large
+ 5 - 0
resources/js/marked.min.js


+ 27 - 11
resources/js/preload.js

@@ -5,7 +5,7 @@ const { ipcRenderer, contextBridge, shell, clipboard, webFrame } = require('elec
 const IS_MAC = process.platform === 'darwin'
 const IS_WIN32 = process.platform === 'win32'
 
-function getFilePathFromClipboard() {
+function getFilePathFromClipboard () {
   if (IS_WIN32) {
     const rawFilePath = clipboard.read('FileNameW')
     return rawFilePath.replace(new RegExp(String.fromCharCode(0), 'g'), '')
@@ -16,7 +16,7 @@ function getFilePathFromClipboard() {
   }
 }
 
-function isClipboardHasImage() {
+function isClipboardHasImage () {
   return !clipboard.readImage().isEmpty()
 }
 
@@ -25,16 +25,32 @@ contextBridge.exposeInMainWorld('apis', {
     return await ipcRenderer.invoke('main', arg)
   },
 
+  addListener: ipcRenderer.on.bind(ipcRenderer),
+  removeListener: ipcRenderer.removeListener.bind(ipcRenderer),
+  removeAllListeners: ipcRenderer.removeAllListeners.bind(ipcRenderer),
+
   on: (channel, callback) => {
     const newCallback = (_, data) => callback(data)
     ipcRenderer.on(channel, newCallback)
   },
 
+  off: (channel, callback) => {
+    if (!callback) {
+      ipcRenderer.removeAllListeners(channel)
+    } else {
+      ipcRenderer.removeListener(channel, callback)
+    }
+  },
+
+  once: (channel, callback) => {
+    ipcRenderer.on(channel, callback)
+  },
+
   checkForUpdates: async (...args) => {
     await ipcRenderer.invoke('check-for-updates', ...args)
   },
 
-  setUpdatesCallback(cb) {
+  setUpdatesCallback (cb) {
     if (typeof cb !== 'function') return
 
     const channel = 'updates-callback'
@@ -42,19 +58,19 @@ contextBridge.exposeInMainWorld('apis', {
     ipcRenderer.on(channel, cb)
   },
 
-  installUpdatesAndQuitApp() {
+  installUpdatesAndQuitApp () {
     ipcRenderer.invoke('install-updates', true)
   },
 
-  async openExternal(url, options) {
+  async openExternal (url, options) {
     await shell.openExternal(url, options)
   },
 
-  async openPath(path) {
+  async openPath (path) {
     await shell.openPath(path)
   },
 
-  showItemInFolder(fullpath) {
+  showItemInFolder (fullpath) {
     if (IS_WIN32) {
       shell.openPath(path.dirname(fullpath))
     } else {
@@ -67,7 +83,7 @@ contextBridge.exposeInMainWorld('apis', {
    *
    * @param {string} html html file with embedded state
    */
-  exportPublishAssets(html, customCSSPath, repoPath, assetFilenames) {
+  exportPublishAssets (html, customCSSPath, repoPath, assetFilenames) {
     ipcRenderer.invoke(
       'export-publish-assets',
       html,
@@ -85,7 +101,7 @@ contextBridge.exposeInMainWorld('apis', {
    * @param from?
    * @returns {Promise<void>}
    */
-  async copyFileToAssets(repoPathRoot, to, from) {
+  async copyFileToAssets (repoPathRoot, to, from) {
     if (from && fs.statSync(from).isDirectory()) {
       throw new Error('not support copy directory')
     }
@@ -120,7 +136,7 @@ contextBridge.exposeInMainWorld('apis', {
     }
   },
 
-  toggleMaxOrMinActiveWindow(isToggleMin = false) {
+  toggleMaxOrMinActiveWindow (isToggleMin = false) {
     ipcRenderer.invoke('toggle-max-or-min-active-win', isToggleMin)
   },
 
@@ -130,7 +146,7 @@ contextBridge.exposeInMainWorld('apis', {
    * @param args
    * @private
    */
-  async _callApplication(type, ...args) {
+  async _callApplication (type, ...args) {
     return await ipcRenderer.invoke('call-application', type, ...args)
   },
 

+ 108 - 0
resources/marketplace.html

@@ -0,0 +1,108 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport"
+        content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
+  <meta http-equiv="X-UA-Compatible" content="ie=edge">
+  <title>Logseq Marketplace</title>
+  <link rel="stylesheet"
+        href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/4.0.0/github-markdown.min.css"
+        integrity="sha512-Oy18vBnbSJkXTndr2n6lDMO5NN31UljR8e/ICzVPrGpSud4Gkckb8yUpqhKuUNoE+o9gAb4O/rAxxw1ojyUVzg=="
+        crossorigin="anonymous" referrerpolicy="no-referrer"/>
+  <style>
+    html, body {
+      padding: 0;
+      margin: 0;
+      box-sizing: border-box;
+      font-family: sans-serif;
+      width: 100%;
+      height: 100%;
+    }
+
+    body {
+      display: flex;
+    }
+
+    #app {
+      display: flex;
+      justify-content: center;
+      flex: 1;
+      width: 900px;
+      min-height: 100%;
+      padding: 30px;
+      margin: 0 auto;
+      color: #333;
+    }
+
+    #app > strong {
+      padding-top: 30vh;
+      font-weight: 400;
+    }
+  </style>
+</head>
+<body>
+<div id="app"></div>
+<script src="./js/marked.min.js"></script>
+<script>
+  ;(async function () {
+    const app = document.getElementById('app')
+    const url = new URL(location.href)
+    const setMsg = (msg) => app.innerHTML = `<strong>${msg}</strong>`
+    const repo = url.searchParams.get('repo')
+    if (!repo) {
+      return setMsg('Repo parameter not found!')
+    }
+
+    const setContent = (content) => app.innerHTML = `<main class="markdown-body">${content}</main>`
+    const endpoint = (repo, branch, file) => `https://raw.githubusercontent.com/${repo}/${branch}/${file}`
+
+    let content = ''
+    let points = [
+      endpoint(repo, 'master', 'README.md'), endpoint(repo, 'main', 'README.md'),
+      endpoint(repo, 'master', 'readme.md'), endpoint(repo, 'main', 'readme.md')]
+    let readme
+
+    setMsg('Loading ...')
+
+    for (let url of points) {
+      try {
+        const res = await fetch(url)
+        if (res.status !== 200) {
+          throw new Error(res.statusText)
+        }
+        content = await res.text()
+        readme = url
+        break
+      } catch (e) {
+        console.debug('Error:', url, e.message)
+      }
+    }
+
+    const fixLink = (link) => readme.replace(/README\.md$/i, link)
+    const isRelative = (href) => href && !href.startsWith('http') && !!href.match(/^([.\/]+|[^\/]+)/) && href.replace(/^[.\/]+/, '')
+    marked.use({
+      renderer: {
+        link (href, title, text) {
+          return `<a href="${href}" target="_blank" title="${title}">${text}</a>`
+        },
+
+        image (href, title, text) {
+          let link = isRelative(href)
+
+          if (link) {
+            link = fixLink(link)
+            return `<img style="max-width: 100%;" src="${link}" alt="${title}" />`
+          }
+
+          return false
+        }
+      }
+    })
+
+    content = marked(content).replace('src="./', `src="${fixLink('')}`)
+    setContent(content)
+  }())
+</script>
+</body>
+</html>

+ 2 - 1
resources/package.json

@@ -29,7 +29,8 @@
     "open": "^7.3.1",
     "semver": "^7.3.5",
     "update-electron-app": "^2.0.1",
-    "simple-git": "2.44.0"
+    "simple-git": "2.44.0",
+    "extract-zip": "2.0.1"
   },
   "devDependencies": {
     "@electron-forge/cli": "^6.0.0-beta.57",

+ 1 - 0
src/electron/electron/configs.cljs

@@ -5,6 +5,7 @@
     ["electron" :refer [^js app] :as electron]
     [cljs.reader :as reader]))
 
+(defonce dot-root (.join path (.getPath app "home") ".logseq"))
 (defonce cfg-root (.getPath app "userData"))
 (defonce cfg-path (.join path cfg-root "configs.edn"))
 

+ 6 - 4
src/electron/electron/core.cljs

@@ -2,7 +2,7 @@
   (:require [electron.handler :as handler]
             [electron.search :as search]
             [electron.updater :refer [init-updater]]
-            [electron.utils :refer [mac? win32? linux? prod? dev? logger open]]
+            [electron.utils :refer [*win mac? win32? linux? prod? dev? logger open]]
             [electron.configs :as cfgs]
             [clojure.string :as string]
             [promesa.core :as p]
@@ -207,7 +207,11 @@
                        (js/decodeURIComponent url) url)
                  url (if-not win32? (string/replace url "file://" "") url)]
              (.. logger (info "new-window" url))
-             (open url))
+             (if (string/includes?
+                   (.normalize path url)
+                   (.join path (. app getAppPath) "index.html"))
+               (.info logger "pass-window" url)
+               (open url)))
            (.preventDefault e)))
 
     (doto win
@@ -218,8 +222,6 @@
          (.removeHandler ipcMain export-publish-assets)
          (.removeHandler ipcMain call-app-channel))))
 
-(defonce *win (atom nil))
-
 (defn- destroy-window!
   [^js win]
   (.destroy win))

+ 35 - 44
src/electron/electron/handler.cljs

@@ -14,7 +14,8 @@
             [electron.state :as state]
             [clojure.core.async :as async]
             [electron.search :as search]
-            [electron.git :as git]))
+            [electron.git :as git]
+            [electron.plugin :as plugin]))
 
 (defmulti handle (fn [_window args] (keyword (first args))))
 
@@ -28,15 +29,15 @@
 (defn- readdir
   [dir]
   (->> (tree-seq
-        (fn [^js f]
-          (.isDirectory (fs/statSync f) ()))
-        (fn [d]
-          (let [files (fs/readdirSync d (clj->js {:withFileTypes true}))]
-            (->> files
-                 (remove #(.isSymbolicLink ^js %))
-                 (remove #(string/starts-with? (.-name ^js %) "."))
-                 (map #(.join path d (.-name %))))))
-        dir)
+         (fn [^js f]
+           (.isDirectory (fs/statSync f) ()))
+         (fn [d]
+           (let [files (fs/readdirSync d (clj->js {:withFileTypes true}))]
+             (->> files
+                  (remove #(.isSymbolicLink ^js %))
+                  (remove #(string/starts-with? (.-name ^js %) "."))
+                  (map #(.join path d (.-name %))))))
+         dir)
        (doall)
        (vec)))
 
@@ -77,7 +78,7 @@
   (fs/statSync path))
 
 (defonce allowed-formats
-  #{:org :markdown :md :edn :json :css :excalidraw})
+         #{:org :markdown :md :edn :json :css :excalidraw})
 
 (defn get-ext
   [p]
@@ -88,40 +89,21 @@
 (defn- get-files
   [path]
   (let [result (->>
-                (readdir path)
-                (remove (partial utils/ignored-path? path))
-                (filter #(contains? allowed-formats (get-ext %)))
-                (map (fn [path]
-                       (let [stat (fs/statSync path)]
-                         (when-not (.isDirectory stat)
-                           {:path (utils/fix-win-path! path)
-                            :content (utils/read-file path)
-                            :stat stat}))))
-                (remove nil?))]
+                 (readdir path)
+                 (remove (partial utils/ignored-path? path))
+                 (filter #(contains? allowed-formats (get-ext %)))
+                 (map (fn [path]
+                        (let [stat (fs/statSync path)]
+                          (when-not (.isDirectory stat)
+                            {:path    (utils/fix-win-path! path)
+                             :content (utils/read-file path)
+                             :stat    stat}))))
+                 (remove nil?))]
     (vec (cons {:path (utils/fix-win-path! path)} result))))
 
-(defn- get-ls-dotdir-root
-  []
-  (let [lg-dir (str (.getPath app "home") "/.logseq")]
-    (if-not (fs/existsSync lg-dir)
-      (and (fs/mkdirSync lg-dir) lg-dir)
-      lg-dir)))
-
-(defn- get-ls-default-plugins
-  []
-  (let [plugins-root (path/join (get-ls-dotdir-root) "plugins")
-        _ (if-not (fs/existsSync plugins-root)
-            (fs/mkdirSync plugins-root))
-        dirs (js->clj (fs/readdirSync plugins-root #js{"withFileTypes" true}))
-        dirs (->> dirs
-                  (filter #(.isDirectory %))
-                  (filter #(not (string/starts-with? (.-name %) "_")))
-                  (map #(path/join plugins-root (.-name %))))]
-    dirs))
-
 (defmethod handle :openDir [^js window _messages]
   (let [result (.showOpenDialogSync dialog (bean/->js
-                                            {:properties ["openDirectory" "createDirectory" "promptToCreate"]}))
+                                             {:properties ["openDirectory" "createDirectory" "promptToCreate"]}))
         path (first result)]
     (.. ^js window -webContents
         (send "open-dir-confirmed"
@@ -132,7 +114,7 @@
   (get-files path))
 
 (defmethod handle :persistent-dbs-saved [window _]
-  (async/put! state/persistent-dbs-chan true )
+  (async/put! state/persistent-dbs-chan true)
   true)
 
 (defmethod handle :search-blocks [window [_ repo q opts]]
@@ -183,10 +165,10 @@
     path))
 
 (defmethod handle :getLogseqDotDirRoot []
-  (get-ls-dotdir-root))
+  (utils/get-ls-dotdir-root))
 
 (defmethod handle :getUserDefaultPlugins []
-  (get-ls-default-plugins))
+  (utils/get-ls-default-plugins))
 
 (defmethod handle :relaunchApp []
   (.relaunch app) (.quit app))
@@ -217,6 +199,15 @@
 (defmethod handle :gitCommitAll [_ [_ message]]
   (git/add-all-and-commit! message))
 
+(defmethod handle :installMarketPlugin [_ [_ mft]]
+  (plugin/install-or-update! mft))
+
+(defmethod handle :updateMarketPlugin [_ [_ pkg]]
+  (plugin/install-or-update! pkg))
+
+(defmethod handle :uninstallMarketPlugin [_ [_ id]]
+  (plugin/uninstall! id))
+
 (defmethod handle :default [args]
   (println "Error: no ipc handler for: " (bean/->js args)))
 

+ 177 - 0
src/electron/electron/plugin.cljs

@@ -0,0 +1,177 @@
+(ns electron.plugin
+  (:require [promesa.core :as p]
+            [cljs-bean.core :as bean]
+            ["semver" :as semver]
+            ["os" :as os]
+            ["fs-extra" :as fs]
+            ["path" :as path]
+            [clojure.string :as string]
+            [electron.utils :refer [logger]]
+            [electron.configs :as cfgs]
+            [electron.utils :refer [*win fetch extract-zip] :as utils]))
+
+;; update & install
+(def *installing-or-updating (atom nil))
+(def debug (fn [& args] (apply (.-info logger) (conj args "[Marketplace]"))))
+(def emit (fn [type payload]
+            (.. ^js @*win -webContents
+                (send (name type) (bean/->js payload)))))
+
+;; Get a release by tag name: /repos/{owner}/{repo}/releases/tags/{tag}
+;; Get the latest release: /repos/{owner}/{repo}/releases/latest
+;; Zipball https://api.github.com/repos/{owner}/{repo}/zipball
+
+(defn fetch-latest-release-asset
+  [{:keys [repo theme]}]
+  (p/catch
+    (p/let [api #(str "https://api.github.com/repos/" repo "/" %)
+            endpoint (api "releases/latest")
+            ^js res (fetch endpoint)
+            res (.json res)
+            _ (js/console.debug "[Release Latest] " endpoint)
+            res (bean/->clj res)
+            version (:tag_name res)
+            asset (first (filter #(= "application/zip" (:content_type %)) (:assets res)))]
+
+      [(if (and (nil? asset) theme)
+         (if-let [zipball (:zipball_url res)]
+           zipball
+           (api "zipball"))
+         asset)
+       version])
+
+    (fn [^js e]
+      (emit :lsp-installed {:status :error :payload e})
+      (throw (js/Error. :release-network-issue)))))
+
+(defn fetch-tag-release-asset
+  [repo tag])
+
+(defn download-asset-zip
+  [{:keys [id repo title author description effect]} url dot-extract-to]
+  (p/catch
+    (p/let [^js res (fetch url)
+            _ (if-not (.-ok res) (throw (js/Error. :download-network-issue)))
+            frm-zip (p/create
+                      (fn [resolve1 reject1]
+                        (let [headers (. res -headers)
+                              body (.-body res)
+                              total-size (js/parseInt (.get headers "content-length"))
+                              start-at (.now js/Date)
+                              *downloaded (atom 0)
+                              dest-basename (path/basename url)
+                              dest-basename (if-not (string/ends-with? dest-basename ".zip")
+                                              (str id "_" dest-basename ".zip") dest-basename)
+                              tmp-dest-file (path/join (os/tmpdir) (str dest-basename ".pending"))
+                              dest-file (.createWriteStream fs tmp-dest-file)]
+                          (doto body
+                            (.on "data" (fn [chunk]
+                                          (let [downloaded (+ @*downloaded (.-length chunk))]
+                                            (.write dest-file chunk)
+                                            (reset! *downloaded downloaded))))
+                            (.on "error" (fn [^js e]
+                                           (reject1 e)))
+                            (.on "end" (fn [^js e]
+                                         (.close dest-file)
+                                         (let [dest-file (string/replace tmp-dest-file ".pending" "")]
+                                           (fs/renameSync tmp-dest-file dest-file)
+                                           (resolve1 dest-file))))))))
+            ;; sync extract
+            zip-extracted-path (string/replace frm-zip ".zip" "")
+
+            _ (extract-zip frm-zip (bean/->js
+                                     {:dir zip-extracted-path}))
+
+            tmp-extracted-root (let [dirs (fs/readdirSync zip-extracted-path)
+                                     pkg? (fn [root]
+                                            (when-let [^js stat (fs/statSync root)]
+                                              (when (.isDirectory stat)
+                                                (fs/pathExistsSync (.join path root "package.json")))))]
+                                 (if (pkg? zip-extracted-path)
+                                   "."
+                                   (last (take-while #(pkg? (.join path zip-extracted-path %)) dirs))))
+
+            _ (if-not tmp-extracted-root
+                (throw (js/Error. :invalid-plugin-package)))
+
+            tmp-extracted-root (.join path zip-extracted-path tmp-extracted-root)
+
+            _ (and (fs/existsSync dot-extract-to)
+                   (fs/removeSync dot-extract-to))
+
+            _ (fs/moveSync tmp-extracted-root dot-extract-to)
+
+            _ (let [src (.join path dot-extract-to "package.json")
+                    ^js pkg (fs/readJsonSync src)]
+                (set! (.-repo pkg) repo)
+                (set! (.-title pkg) title)
+                (set! (.-author pkg) author)
+                (set! (.-description pkg) description)
+                (set! (.-effect pkg) (boolean effect))
+                (fs/writeJsonSync src pkg))
+
+            _ (do
+                (fs/removeSync zip-extracted-path)
+                (fs/removeSync frm-zip))]
+      true)
+    (fn [^js e]
+      (emit :lsp-installed {:status :error :payload e})
+      (throw e))))
+
+(defn install-or-update!
+  [{:keys [version repo] :as item}]
+  (when (and (not @*installing-or-updating) repo)
+    (let [updating? (and version (. semver valid version))]
+
+      (js/console.debug (if updating? "Updating:" "Installing:") repo)
+
+      (-> (p/create
+            (fn [resolve reject]
+              (reset! *installing-or-updating item)
+              ;; get releases
+              (-> (p/let [[asset latest-version] (fetch-latest-release-asset item)
+
+                          _ (debug "[Release Asset] #" latest-version " =>" (:url asset))
+
+                          ;; compare latest version
+                          _ (when (and updating? latest-version
+                                       (. semver valid latest-version))
+
+                              (debug "[Updating Latest?] " version " > " latest-version)
+
+                              (if (. semver lt version latest-version)
+                                (debug "[Updating Latest] " latest-version)
+                                (throw (js/Error. :no-new-version))))
+
+                          dl-url (if-not (string? asset)
+                                   (:browser_download_url asset) asset)
+
+                          _ (when-not dl-url
+                              (throw (js/Error. :release-asset-not-found)))
+
+                          dest (.join path cfgs/dot-root "plugins" (:id item))
+                          _ (download-asset-zip item dl-url dest)
+                          _ (debug "[Updated DONE] " latest-version)]
+
+                    (emit :lsp-installed
+                          {:status  :completed
+                           :payload (assoc item :zip dl-url :dst dest)})
+
+                    (resolve))
+                  (p/catch
+                    (fn [^js e]
+                      (emit :lsp-installed
+                            {:status  :error
+                             :payload (.-message e)}))
+                    (resolve nil)))))
+
+          (p/finally #(reset! *installing-or-updating nil))))))
+
+(defn uninstall!
+  [id]
+  (let [id (string/replace id #"^[.\/]+" "")
+        plugin-path (.join path (utils/get-ls-dotdir-root) "plugins" id)
+        settings-path (.join path (utils/get-ls-dotdir-root) "settings" (str id ".json"))]
+    (when (fs/pathExistsSync plugin-path)
+      (fs/removeSync plugin-path)
+      (fs/removeSync settings-path))))

+ 1 - 1
src/electron/electron/updater.cljs

@@ -46,7 +46,7 @@
        (throw e)))))
 
 (defn check-for-updates
-  [{:keys           [repo ^js ^js win]
+  [{:keys           [repo ^js win]
     [auto-download] :args}]
   (let [emit (fn [type payload]
                (.. win -webContents

+ 23 - 2
src/electron/electron/utils.cljs

@@ -1,11 +1,12 @@
 (ns electron.utils
   (:require [clojure.string :as string]
-            ["fs" :as fs]
+            ["fs-extra" :as fs]
             ["path" :as path]
             [clojure.string :as string]
             [cljs-bean.core :as bean]
-            ["electron" :refer [BrowserWindow]]))
+            ["electron" :refer [app BrowserWindow]]))
 
+(defonce *win (atom nil))
 (defonce mac? (= (.-platform js/process) "darwin"))
 (defonce win32? (= (.-platform js/process) "win32"))
 (defonce linux? (= (.-platform js/process) "linux"))
@@ -16,6 +17,26 @@
 
 (defonce open (js/require "open"))
 (defonce fetch (js/require "node-fetch"))
+(defonce extract-zip (js/require "extract-zip"))
+
+(defn get-ls-dotdir-root
+  []
+  (let [lg-dir (str (.getPath app "home") "/.logseq")]
+    (if-not (fs/existsSync lg-dir)
+      (and (fs/mkdirSync lg-dir) lg-dir)
+      lg-dir)))
+
+(defn get-ls-default-plugins
+  []
+  (let [plugins-root (path/join (get-ls-dotdir-root) "plugins")
+        _ (if-not (fs/existsSync plugins-root)
+            (fs/mkdirSync plugins-root))
+        dirs (js->clj (fs/readdirSync plugins-root #js{"withFileTypes" true}))
+        dirs (->> dirs
+                  (filter #(.isDirectory %))
+                  (filter #(not (string/starts-with? (.-name %) "_")))
+                  (map #(path/join plugins-root (.-name %))))]
+    dirs))
 
 (defn ignored-path?
   [dir path]

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

@@ -67,4 +67,4 @@
        #(command-palette {:commands (cp/get-commands)})
        {:fullscreen? false
         :close-btn?  false}))
-    nil))
+    nil))

+ 7 - 4
src/main/frontend/components/header.cljs

@@ -187,6 +187,9 @@
          (search/search)
          [:div.flex-1])
 
+       (when plugin-handler/lsp-enabled?
+         (plugins/hook-ui-items :toolbar))
+
        [:a (when refreshing?
              [:div {:class "animate-spin-reverse"}
               svg/refresh])]
@@ -194,17 +197,17 @@
        (when electron-mac?
          (logo {:white? white?
                 :electron-mac? true}))
-
        (when electron-mac? (back-and-forward true))
 
        (new-block-mode)
 
+       (when refreshing?
+         [:div {:class "animate-spin-reverse"}
+          svg/refresh])
+
        (when-not (util/electron?)
          (login logged?))
 
-       (when plugin-handler/lsp-enabled?
-         (plugins/hook-ui-items :toolbar))
-
        (repo/sync-status current-repo)
 
        [:div.repos

+ 11 - 5
src/main/frontend/components/page.cljs

@@ -366,6 +366,7 @@
                               page-name)]
                    (db/get-page-format page))
           journal? (db/journal-page? page-name)
+          fmt-journal? (boolean (date/journal-title->int page-name))
           sidebar? (:sidebar? option)]
       (rum/with-context [[t] i18n/*tongue-context*]
         (let [route-page-name path-page-name
@@ -387,10 +388,14 @@
                       (= page-name (string/lower-case (date/journal-name))))
               developer-mode? (state/sub [:ui/developer-mode?])
               public? (true? (:public properties))]
-          [:div.flex-1.page.relative (if (seq (:block/tags page))
-                                       (let [page-names (model/get-page-names-by-ids (map :db/id (:block/tags page)))]
-                                         {:data-page-tags (text/build-data-value page-names)})
-                                       {})
+          [:div.flex-1.page.relative
+           (merge (if (seq (:block/tags page))
+                    (let [page-names (model/get-page-names-by-ids (map :db/id (:block/tags page)))]
+                      {:data-page-tags (text/build-data-value page-names)})
+                    {})
+
+             {:class (util/classnames [{:is-journals (or journal? fmt-journal?)}])})
+
            [:div.relative
             (when (and (not sidebar?)
                        (not block?))
@@ -416,7 +421,8 @@
                                   page-name
                                   path-page-name))]
                     (if (pdf-assets/hls-file? title)
-                      (pdf-assets/human-hls-filename-display title) title))]]]
+                      (pdf-assets/human-hls-filename-display title)
+                      (if fmt-journal? (date/journal-title->custom-format title) title)))]]]
                (when (not config/publishing?)
                  [:div.flex.flex-row
                   (when plugin-handler/lsp-enabled?

+ 354 - 134
src/main/frontend/components/plugins.cljs

@@ -1,65 +1,106 @@
 (ns frontend.components.plugins
-  (:require [cljs-bean.core :as bean]
-            [clojure.string :as string]
-            [frontend.components.svg :as svg]
-            [frontend.handler.notification :as notification]
-            [frontend.handler.plugin :as plugin-handler]
+  (:require [rum.core :as rum]
             [frontend.state :as state]
+            [cljs-bean.core :as bean]
+            [frontend.context.i18n :as i18n]
             [frontend.ui :as ui]
             [frontend.util :as util]
+            [frontend.mixins :as mixins]
+            [electron.ipc :as ipc]
             [promesa.core :as p]
-            [rum.core :as rum]))
+            [frontend.components.svg :as svg]
+            [frontend.handler.notification :as notification]
+            [frontend.handler.plugin :as plugin-handler]
+            [clojure.string :as string]))
 
-(rum/defc installed-themes
+(rum/defcs installed-themes
   < rum/reactive
-  []
-  (let [themes (state/sub :plugin/installed-themes)
+    (rum/local 0 ::cursor)
+    (rum/local 0 ::total)
+    (mixins/event-mixin
+      (fn [state]
+        (let [*cursor (::cursor state)
+              *total (::total state)
+              ^js target (rum/dom-node state)]
+          (.focus target)
+          (mixins/on-key-down
+            state {38                                       ;; up
+                   (fn [^js e]
+                     (reset! *cursor
+                             (if (zero? @*cursor)
+                               (dec @*total) (dec @*cursor))))
+                   40                                       ;; down
+                   (fn [^js e]
+                     (reset! *cursor
+                             (if (= @*cursor (dec @*total))
+                               0 (inc @*cursor))))
+
+                   13                                       ;; enter
+                   #(when-let [^js active (.querySelector target ".is-active")]
+                      (.click active))
+                   }))))
+  [state]
+  (let [*cursor (::cursor state)
+        *total (::total state)
+        themes (state/sub :plugin/installed-themes)
         selected (state/sub :plugin/selected-theme)
-        themes (cons {:name "Default Theme" :url nil :description "Logseq default light/dark theme."} themes)]
-
-    [: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    [(when current-selected "is-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
-           (when current-selected "current")]]))]))
+        themes (cons {:name "Default Theme" :url nil :description "Logseq default light/dark theme."} themes)
+        themes (sort #(:selected %) (map #(assoc % :selected (= (:url %) selected)) themes))
+        _ (reset! *total (count themes))]
+
+    (rum/with-context
+      [[t] i18n/*tongue-context*]
+
+      [:div.cp__themes-installed
+       {:tab-index -1}
+       [:h1.mb-4.text-2xl.p-2 (t :themes)]
+       (map-indexed
+         (fn [idx opt]
+           (let [current-selected (:selected opt)
+                 plg (get (:plugin/installed-plugins @state/state) (keyword (:pid opt)))]
+             [:div.it.flex.px-3.py-1.5.rounded-sm.justify-between
+              {:key      (:url opt)
+               :title    (if current-selected "Cancel selected theme")
+               :class    (util/classnames
+                           [{:is-selected current-selected
+                             :is-active   (= idx @*cursor)}])
+               :on-click #(do (js/LSPluginCore.selectTheme (if current-selected nil (clj->js opt)))
+                              (state/set-modal! nil))}
+              [:section
+               [:strong.block (when plg (str (:name plg) " / ")) (:name opt)]
+               [:small.opacity-30.italic (:description opt)]]
+              [:small.flex-shrink-0.flex.items-center.opacity-10
+               (if current-selected (svg/check 28))]]))
+         themes)])))
 
 (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])
+    (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/defc local-markdown-display
   < rum/reactive
   []
   (let [[content item] (state/sub :plugin/active-readme)]
@@ -73,90 +114,240 @@
      (when-let [repo (:repository item)]
        (when-let [repo (if (string? repo) repo (:url repo))]
          [:div.p-4.rounded-md.bg-base-3
-          [:strong [:a.flex.items-center {:target "_blank" :href repo} [:span.mr-1 (svg/github {:width 25 :height 25})] repo]]]))
+          [:strong [:a.flex.items-center {:target "_blank" :href repo}
+                    [:span.mr-1 (svg/github {:width 25 :height 25})] repo]]]))
      [:div.p-1.bg-transparent.border-none.ls-block
       {:style                   {:min-height "60vw"
                                  :max-width  900}
        :dangerouslySetInnerHTML {:__html 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 item 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 item 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 #(when usf (js/apis.openPath usf))} "Open settings"]
-          [:li {:on-click #(js/apis.openPath url)} "Open plugin package"]
-          [: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
+(rum/defc remote-readme-display
+  [repo content]
+
+  (let [src (str "lsp://logseq.com/marketplace.html?repo=" repo)]
+    [:iframe.lsp-frame-readme {:src src}]))
+
+(defn security-warning
   []
+  (ui/admonition
+    :warning
+    [:div.max-w-4xl
+     "Plugins can access your graph and your local files, issue network requests.
+      They can also cause data corruption or loss. We're working on proper access rules for your graphs.
+      Meanwhile, make sure you have regular backups of your graphs and only install the plugins when you can read and
+      understand the source code."]))
+
+(rum/defc plugin-item-card < rum/static
+  [{:keys [id name title settings version url description author icon usf iir repo] :as item}
+   installing-or-updating? installed? stat]
+
+  (let [market? (and (not (nil? repo)) (nil? usf))
+        disabled (:disabled settings)
+        name (or title name "Untitled")]
+    (rum/with-context
+      [[t] i18n/*tongue-context*]
+
+      [:div.cp__plugins-item-card
+       {:class (util/classnames [{:market market?}])}
+
+       [:div.l.link-block
+        {:on-click #(plugin-handler/open-readme!
+                      url item (if repo remote-readme-display local-markdown-display))}
+        (if (and icon (not (string/blank? icon)))
+          [:img.icon {:src (if market? (plugin-handler/pkg-asset id icon) icon)}]
+          svg/folder)
+
+        (when-not (or market? iir)
+          [:span.flex.justify-center.text-xs.text-red-500.pt-2 "unpacked"])]
+
+       [:div.r
+        [:h3.head.text-xl.font-bold.pt-1.5
+
+         [:span name]
+         (if (not market?) [: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.pr-2.flex.justify-between
+          [:small author]
+          [:small {:on-click #(do
+                                (notification/show! "Copied!" :success)
+                                (util/copy-to-clipboard! id))}
+           (str "ID: " id)]]]
+
+        [:div.flag.is-top.opacity-50
+         (if repo
+           [:a.flex {:target "_blank"
+                     :href   (plugin-handler/gh-repo-url repo)}
+            (svg/github {:width 16 :height 16})])]
+
+        (if market?
+          ;; market ctls
+          [:div.ctl
+           [:ul.l.flex.items-center
+            ;; downloads
+            [:li.flex.text-sm.items-center.pr-3 (svg/star 16) [:span.pl-1 (:stargazers_count stat)]]
+
+            ;; stars
+            (when-let [downloads (and stat (reduce (fn [a b] (+ a (get b 2))) 0 (:releases stat)))]
+              (if (and downloads (> downloads 0))
+                [:li.flex.text-sm.items-center.pr-3 (svg/cloud-down 16) [:span.pl-1 downloads]]))]
+
+           [:div.r.flex.items-center
+
+            [:a.btn
+             {:class    (util/classnames [{:disabled   (or installed? installing-or-updating?)
+                                           :installing installing-or-updating?}])
+              :on-click #(plugin-handler/install-marketplace-plugin item)}
+             (if installed?
+               (t :plugin/installed)
+               (if installing-or-updating?
+                 [:span.flex.items-center [:small svg/loading]
+                  (t :plugin/installing)]
+                 (t :plugin/install)))]]]
+
+          ;; installed ctls
+          [:div.ctl
+           [:div.l
+            [:div.de
+             [:strong (svg/settings)]
+             [:ul.menu-list
+              [:li {:on-click #(if usf (js/apis.openPath usf))} (t :plugin/open-settings)]
+              [:li {:on-click #(js/apis.openPath url)} (t :plugin/open-package)]
+              [: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))}
+               (t :plugin/uninstall)]]]]
+
+           [:div.r.flex.items-center
+            (if (not disabled)
+              [:a.btn
+               {:on-click #(js-invoke js/LSPluginCore "reload" id)}
+               (t :plugin/reload)])
+
+            (if iir
+              [:a.btn
+               {:class    (util/classnames [{:disabled (or installing-or-updating?)
+                                             :updating installing-or-updating?}])
+                :on-click #(plugin-handler/update-marketplace-plugin
+                             item (fn [e] (notification/show! e :error)))}
+
+               (if installing-or-updating?
+                 (t :plugin/updating)
+                 (t :plugin/update))])
+
+            (ui/toggle (not disabled)
+                       (fn []
+                         (js-invoke js/LSPluginCore (if disabled "enable" "disable") id))
+                       true)]])]])))
+
+(rum/defcs marketplace-plugins
+  < rum/static rum/reactive
+    (rum/local false ::fetching)
+    (rum/local nil ::error)
+    {:did-mount (fn [s]
+                  (reset! (::fetching s) true)
+                  (reset! (::error s) nil)
+                  (-> (plugin-handler/load-marketplace-plugins false)
+                      (p/then #(plugin-handler/load-marketplace-stats false))
+                      (p/catch #(do (js/console.error %) (reset! (::error s) %)))
+                      (p/finally #(reset! (::fetching s) false)))
+                  s)}
+  [state]
+  (let [pkgs (state/sub :plugin/marketplace-pkgs)
+        stats (state/sub :plugin/marketplace-stats)
+        installed-plugins (state/sub :plugin/installed-plugins)
+        installing (state/sub :plugin/installing)
+        online? (state/sub :network/online?)
+        *fetching (::fetching state)
+        *error (::error state)]
+
+    (cond
+      (not online?)
+      [:p.flex.justify-center.pt-20.opacity-50
+       (svg/offline 30)]
+
+      @*fetching
+      [:p.flex.justify-center.pt-20
+       svg/loading]
+
+      @*error
+      [:p.flex.justify-center.pt-20.opacity-50
+       "Remote error: " (.-message @*error)]
+
+      :else
+      [:div.cp__plugins-marketplace
+       {:class (util/classnames [{:has-installing (boolean installing)}])}
+       [:div.cp__plugins-item-lists.grid-cols-1.md:grid-cols-2.lg:grid-cols-3
+        (for [item pkgs]
+          (rum/with-key
+            (let [pid (keyword (:id item))]
+              (plugin-item-card
+                item (and installing (= (keyword (:id installing)) pid))
+                (contains? installed-plugins pid)
+                (get stats pid)))
+            (:id item)))]])))
+
+(rum/defcs installed-plugins
+  < rum/static rum/reactive
+  [state]
   (let [installed-plugins (state/sub :plugin/installed-plugins)
+        updating (state/sub :plugin/installing)
         selected-unpacked-pkg (state/sub :plugin/selected-unpacked-pkg)]
-    [:div.cp__plugins-page-installed
-     [:h1 "Installed Plugins"]
-     (ui/admonition
-      :warning
-      [:div {:style {:max-width 700}}
-       "Plugins can access your graph and your local files, issue network requests. They can also cause data corruption or loss. We're working on proper access rules for your graphs. Meanwhile, make sure you have regular backups of your graphs and only install the plugins when you can read and understand the source code."])
-     [:hr]
-     [: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?)
+    (rum/with-context
+      [[t] i18n/*tongue-context*]
+
+      [:div.cp__plugins-installed
+       [:div.mb-4.flex.items-center.justify-between
         (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)))]]))
+          (t :plugin/load-unpacked)
+          :intent "logseq"
+          :on-click plugin-handler/load-unpacked-plugin)
+        (unpacked-plugin-loader selected-unpacked-pkg)
+
+        (when (util/electron?)
+          [:div.flex.align-items
+           (ui/button
+             (t :plugin/open-preferences)
+             :intent "logseq"
+             :on-click (fn []
+                         (p/let [root (plugin-handler/get-ls-dotdir-root)]
+                           (js/apis.openPath (str root "/preferences.json")))))
+
+           (ui/button
+             [:span.flex.items-center
+              [:span.pr-1
+               (ui/Tippy
+                 {:html     [:small.inline-flex.py-2.pr-2
+                             {:style {:max-width "180px" :text-align "left" :justify-content "flex-start"}}
+                             (t :plugin/marketplace-tips)]
+                  :arrow    true
+                  :distance 18
+                  :offset   -25
+                  :theme    "transparent"}
+                 (svg/info))]
+
+              (t :plugin/restart)]
+             :class "ml-2"
+             :intent "logseq"
+             :on-click #(plugin-handler/invoke-exported-api "relaunch"))])]
+
+       [:div.cp__plugins-item-lists.grid-cols-1.md:grid-cols-2.lg:grid-cols-3
+        (for [[_ item] installed-plugins]
+          (rum/with-key
+            (let [pid (keyword (:id item))]
+              (plugin-item-card
+                item (and updating (= (keyword (:id updating)) pid))
+                true nil)) (:id item)))]])))
 
 (defn open-select-theme!
   []
@@ -167,10 +358,10 @@
   ([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)
-        #())
-      [])
+       (fn []
+         (plugin-handler/hook-plugin-app type {:slot id :payload payload} nil)
+         #())
+       [])
      [:div.lsp-hook-ui-slot
       (merge opts {:id id})])))
 
@@ -181,11 +372,11 @@
         ^js pl (js/LSPluginCore.registeredPlugins.get (name pid))]
 
     (rum/use-effect!
-     (fn []
-       (when-let [^js el (rum/deref *el)]
-         (js/LSPlugin.pluginHelpers.setupInjectedUI.call
-          pl #js {:slot (.-id el) :key key :template template} #js {})))
-     [])
+      (fn []
+        (when-let [^js el (rum/deref *el)]
+          (js/LSPlugin.pluginHelpers.setupInjectedUI.call
+            pl #js {:slot (.-id el) :key key :template template} #js {})))
+      [])
 
     (if-not (nil? pl)
       [:div {:id    (uni (str (name key) "-" (name pid)))
@@ -194,10 +385,10 @@
       [: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)]
@@ -206,3 +397,32 @@
                :data-type (name type)}
          (for [[_ {:keys [key template] :as opts} pid] items]
            (rum/with-key (ui-item-renderer pid type opts) key))]))))
+
+(rum/defc plugins-page
+  []
+
+  (let [[active set-active!] (rum/use-state :installed)
+        market? (= active :marketplace)]
+
+    (rum/with-context
+      [[t] i18n/*tongue-context*]
+
+      [:div.cp__plugins-page
+       [:h1 (t :plugins)]
+       (security-warning)
+       [:hr]
+
+       [:div.tabs.flex.items-center.justify-center
+        [:div.tabs-inner.flex.items-center
+         (ui/button [:span.it (t :plugin/installed)]
+                    :on-click #(set-active! :installed)
+                    :intent "logseq" :class (if-not market? "active" ""))
+
+         (ui/button [:span.mk (svg/apps 16) (t :plugin/marketplace)]
+                    :on-click #(set-active! :marketplace)
+                    :intent "logseq" :class (if market? "active" ""))]]
+
+       [:div.panels
+        (if market?
+          (marketplace-plugins)
+          (installed-plugins))]])))

+ 174 - 37
src/main/frontend/components/plugins.css

@@ -1,11 +1,48 @@
 .cp__plugins {
-  &-page-installed {
+  &-page {
+    > h1 {
+      padding: 0 0 20px;
+      font-size: 38px;
+    }
+
+    .tabs {
+      .ui__button {
+        background-color: transparent;
+        margin: 0 8px;
+
+        > span {
+          display: flex;
+          align-items: center;
+          font-size: 16px;
+          font-weight: normal;
+
+          svg {
+            margin-right: 6px;
+          }
+        }
+
+        &.active {
+          background-color: var(--ls-tertiary-background-color);
+        }
+      }
+    }
+  }
+
+  &-installed {
+    min-height: 60vh;
+    padding-top: 5px;
+  }
+
+  &-marketplace {
     min-height: 60vh;
     padding-top: 20px;
 
-    > h1 {
-      padding: 20px 0;
-      font-size: 38px;
+    &.has-installing {
+      .ctl {
+        a.btn {
+          pointer-events: none;
+        }
+      }
     }
   }
 
@@ -17,27 +54,15 @@
     @apply flex py-3 px-1 rounded-md;
 
     background-color: var(--ls-secondary-background-color);
-    height: 180px;
+    height: 160px;
 
-    svg, .icon {
-      width: 70px;
-      height: 70px;
-      opacity: .8;
-
-      &:hover {
-        opacity: 1;
-      }
+    li {
+      margin: 0;
     }
 
     .head {
       max-height: 60px;
       overflow: hidden;
-
-      cursor: pointer;
-
-      &:active {
-        opacity: .8;
-      }
     }
 
     .desc {
@@ -47,13 +72,44 @@
 
     .flag {
       position: absolute;
-      bottom: 20px;
+      bottom: 24px;
       left: 0;
       width: 100%;
+
+      > p {
+        color: var(--ls-primary-text-color);
+        opacity: .3;
+
+        > small:last-child {
+          cursor: pointer;
+        }
+      }
+
+      &.is-top {
+        width: unset;
+        top: 4px;
+        right: 6px;
+        left: unset;
+        bottom: unset;
+
+        &:hover {
+          opacity: .8;
+        }
+      }
     }
 
     > .l {
       padding: 8px;
+
+      svg, .icon {
+        width: 70px;
+        height: 70px;
+        opacity: .8;
+
+        &:hover {
+          opacity: 1;
+        }
+      }
     }
 
     > .r {
@@ -68,7 +124,7 @@
         @apply flex pl-2 items-center justify-between absolute w-full;
 
         bottom: -8px;
-        right: 8px;
+        right: 7px;
 
         .de {
           font-size: 10px;
@@ -116,26 +172,73 @@
           }
 
           svg {
-            width: 13px;
-            height: 13px;
+            width: 15px;
+            height: 15px;
+          }
+
+          &:hover {
+            opacity: .9;
+
+            .menu-list {
+              display: block;
+            }
           }
         }
 
         > .l {
           @apply flex items-center;
-
           margin-left: -80px;
+        }
 
-          .de {
-            &:hover {
-              opacity: .9;
+        a.btn {
+          display: inline-flex;
+          align-items: center;
+          line-height: 1;
+          padding: 4px;
+          font-size: 13px;
+          border-radius: 2px;
+          user-select: none;
+          position: relative;
+          margin-left: 4px;
 
-              .menu-list {
-                display: block;
-              }
+          svg {
+            opacity: .3;
+          }
+
+          &:hover {
+            svg {
+              opacity: .8;
+            }
+          }
+
+          &:active {
+            opacity: .6;
+          }
+
+          &.disabled {
+            pointer-events: none;
+            cursor: default;
+          }
+
+          &.installing {
+            small {
+              position: absolute;
+              transform: scale(.5) translateX(-180%);
+              margin-right: 2px;
             }
           }
         }
+
+        .ui__toggle {
+          margin-left: 5px;
+        }
+      }
+    }
+
+    &.market {
+      .ctl {
+        padding-left: 12px;
+        bottom: -5px;
       }
     }
   }
@@ -146,17 +249,38 @@
 
 .cp__themes {
   &-installed {
-    min-width: 480px;
+    margin: -2rem;
+    outline: none;
+    padding: 1rem;
 
     > .it {
       user-select: none;
-      cursor: pointer;
       background-color: var(--ls-secondary-background-color);
       border: 1px solid transparent;
-      transition: background-color .3s;
+      margin-bottom: 4px;
+      cursor: pointer;
+      opacity: .8;
 
-      &:hover, &.is-selected {
-        background-color: var(--ls-quaternary-background-color);
+      > section {
+        line-height: 1.1em;
+
+        > strong {
+          font-size: 14px;
+        }
+
+        > small {
+          font-size: 11px;
+        }
+      }
+
+      &.is-active {
+        background-color: var(--ls-tertiary-background-color);
+        border:1px solid var(--ls-quaternary-background-color);
+        opacity: 1;
+      }
+
+      &:hover {
+        opacity: 1;
       }
     }
   }
@@ -183,10 +307,10 @@
 
 .ui-items-container {
   &[data-type=toolbar] {
-    @apply flex items-center mt-1 pl-2;
+    @apply flex items-center mt-1;
 
     > .injected-ui-item-toolbar {
-      @apply px-2 opacity-50 hover:opacity-100 transition-opacity;
+      @apply opacity-70 hover:opacity-100 transition-opacity;
     }
   }
 
@@ -199,6 +323,19 @@
   }
 }
 
+.lsp-frame-readme {
+  margin: -2rem;
+  min-height: 75vh;
+  min-width: 900px;
+}
+
+
+html[data-theme='dark'] {
+  .lsp-frame-readme {
+    background-color: #eeeeee;
+  }
+}
+
 .block-content {
   .lsp-hook-ui-slot {
     display: flex;

+ 3 - 4
src/main/frontend/components/sidebar.cljs

@@ -112,9 +112,7 @@
                      [:div#sidebar-nav-wrapper.flex-col.pt-4.hidden.sm:block
                       {:style {:flex (if (state/get-left-sidebar-open?)
                                        "0 1 20%"
-                                       "0 0 0px")
-                               :border-right (str "1px solid "
-                                                  (if white? "#f0f8ff" "#073642"))}}
+                                       "0 0 0px")}}
                       (when (state/sub :ui/left-sidebar-open?)
                         (sidebar-nav route-match nil))]
                      [:div#main-content-container.w-full.flex.justify-center
@@ -319,7 +317,8 @@
         default-home (get-default-home-if-valid)]
     (rum/with-context [[t] i18n/*tongue-context*]
       (theme/container
-       {:theme         theme
+       {:t             t
+        :theme         theme
         :route         route-match
         :current-repo  current-repo
         :nfs-granted?  granted?

+ 50 - 2
src/main/frontend/components/svg.cljs

@@ -129,7 +129,7 @@
   ([d]
    (hero-icon d {}))
   ([d options]
-   [:svg (merge {:fill "currentColor", :view-box "0 0 24 24", :height "24", :width "24"}
+   [:svg (merge {:fill "currentColor", :viewBox "0 0 24 24", :height "24", :width "24"}
                 options)
     [:path
      {:stroke-linejoin "round"
@@ -396,7 +396,7 @@
 
 (rum/defc logo
   [dark?]
-  [:svg.svg-shadow
+  [:svg
    {:fill (if dark? "currentColor" "#002B36"), :view-box "0 0 21 21", :height "21", :width "21"}
    [:ellipse
     {:transform
@@ -659,5 +659,53 @@
    [:svg.icon {:fill "none" :width size :height size :viewBox "0 0 24 24" :stroke "currentColor"}
     [:path {:stroke-linecap "round" :stroke-linejoin "round" :stroke-width "2" :d "M5 13l4 4L19 7"}]]))
 
+(defn cloud-down
+  ([] (cloud-down 16))
+  ([size]
+   [:svg.icon {:width size :height size :viewBox "0 0 24 24" :stroke-width "2" :stroke "currentColor" :fill "none" :stroke-linecap "round" :stroke-linejoin "round"}
+    [:path {:stroke "none" :d "M0 0h24v24H0z" :fill "none"}]
+    [:path {:d "M19 18a3.5 3.5 0 0 0 0 -7h-1a5 4.5 0 0 0 -11 -2a4.6 4.4 0 0 0 -2.1 8.4"}]
+    [:line {:x1 "12" :y1 "13" :x2 "12" :y2 "22"}]
+    [:polyline {:points "9 19 12 22 15 19"}]]))
+
+(defn star
+  ([] (star 16))
+  ([size]
+   [:svg.icon {:width size :height size :viewBox "0 0 24 24" :stroke-width "2" :stroke "currentColor" :fill "none" :stroke-linecap "round" :stroke-linejoin "round"}
+    [:path {:stroke "none" :d "M0 0h24v24H0z" :fill "none"}]
+    [:path {:d "M12 17.75l-6.172 3.245l1.179 -6.873l-5 -4.867l6.9 -1l3.086 -6.253l3.086 6.253l6.9 1l-5 4.867l1.179 6.873z"}]]))
+
+(defn apps
+  ([] (apps 16))
+  ([size]
+   [:svg.icon-apps {:width size :height size :viewBox "0 0 24 24" :stroke-width "2" :stroke "currentColor" :fill "none" :stroke-linecap "round" :stroke-linejoin "round"}
+    [:path {:stroke "none" :d "M0 0h24v24H0z" :fill "none"}]
+    [:rect {:x "4" :y "4" :width "6" :height "6" :rx "1"}]
+    [:rect {:x "4" :y "14" :width "6" :height "6" :rx "1"}]
+    [:rect {:x "14" :y "14" :width "6" :height "6" :rx "1"}]
+    [:line {:x1 "14" :y1 "7" :x2 "20" :y2 "7"}]
+    [:line {:x1 "17" :y1 "4" :x2 "17" :y2 "10"}]]))
+
+(defn reload
+  ([] (reload 16))
+  ([size]
+   [:svg.icon-reload {:width size :height size :viewBox "0 0 24 24" :stroke-width "2" :stroke "currentColor" :fill "none" :stroke-linecap "round" :stroke-linejoin "round"}
+    [:path {:stroke "none" :d "M0 0h24v24H0z" :fill "none"}]
+    [:path {:d "M4.05 11a8 8 0 1 1 .5 4m-.5 5v-5h5"}]]))
+
+(defn settings
+  ([] (settings 16))
+  ([size]
+   [:svg.icon-settings {:width size :height size :viewBox "0 0 24 24" :stroke-width "2" :stroke "currentColor" :fill "none" :stroke-linecap "round" :stroke-linejoin "round"}
+    [:path {:stroke "none" :d "M0 0h24v24H0z" :fill "none"}]
+    [:path {:d "M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"}]
+    [:circle {:cx "12" :cy "12" :r "3"}]]))
+
+(defn offline
+  ([] (offline 16))
+  ([size]
+   [:svg.icon-offline {:viewBox "0 0 1024 1024" :width size :height size}
+    [:path {:d "M512 183.466667c149.333333 0 292.266667 46.933333 409.6 132.266666 19.2 12.8 23.466667 40.533333 8.533333 59.733334-12.8 19.2-40.533333 23.466667-59.733333 8.533333-102.4-74.666667-228.266667-115.2-358.4-115.2-130.133333 0-256 40.533333-358.4 115.2-19.2 12.8-44.8 8.533333-59.733333-8.533333-12.8-19.2-8.533333-44.8 8.533333-59.733334 119.466667-85.333333 260.266667-132.266667 409.6-132.266666z m0 170.666666c108.8 0 211.2 32 298.666667 91.733334 19.2 12.8 23.466667 40.533333 10.666666 59.733333-12.8 19.2-40.533333 23.466667-59.733333 10.666667-72.533333-51.2-160-78.933333-251.733333-78.933334-91.733333 0-177.066667 27.733333-249.6 76.8-19.2 12.8-44.8 8.533333-59.733334-10.666666-12.8-19.2-8.533333-44.8 10.666667-59.733334 89.6-57.6 192-89.6 300.8-89.6z m0 168.533334c23.466667 0 42.666667 19.2 42.666667 42.666666s-19.2 42.666667-42.666667 42.666667c-51.2 0-100.266667 14.933333-142.933333 40.533333-19.2 12.8-46.933333 6.4-57.6-14.933333-12.8-19.2-6.4-46.933333 14.933333-57.6 53.333333-34.133333 117.333333-53.333333 185.6-53.333333z m0 189.866666c34.133333 0 64 27.733333 64 64 0 34.133333-27.733333 64-64 64s-64-27.733333-64-64c0-34.133333 27.733333-64 64-64z m164.266667-106.666666l89.6 89.6 89.6-89.6c17.066667-17.066667 42.666667-17.066667 59.733333 0 17.066667 17.066667 17.066667 42.666667 0 59.733333l-89.6 89.6 89.6 89.6c17.066667 17.066667 17.066667 42.666667 0 59.733333-17.066667 17.066667-42.666667 17.066667-59.733333 0l-89.6-89.6-89.6 89.6c-17.066667 17.066667-42.666667 17.066667-59.733334 0-17.066667-17.066667-17.066667-42.666667 0-59.733333l89.6-89.6-89.6-89.6c-17.066667-17.066667-17.066667-42.666667 0-59.733333 14.933333-17.066667 42.666667-17.066667 59.733334 0z" :fill "currentColor"}]]))
+
 (def arrow-expand
   (hero-icon "M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"))

+ 12 - 7
src/main/frontend/components/theme.cljs

@@ -1,6 +1,6 @@
 (ns frontend.components.theme
   (:require [frontend.extensions.pdf.highlights :as pdf]
-            [frontend.handler.plugin :as plugin-handler]
+            [frontend.handler.plugin :refer [lsp-enabled?] :as plugin-handler]
             [frontend.handler.route :as route-handler]
             [frontend.handler.ui :as ui-handler]
             [frontend.ui :as ui]
@@ -8,7 +8,7 @@
             [rum.core :as rum]))
 
 (rum/defc container
-  [{:keys [route theme on-click current-repo nfs-granted? db-restoring? sidebar-open? system-theme?] :as props} child]
+  [{:keys [t route theme on-click current-repo nfs-granted? db-restoring? sidebar-open? system-theme?] :as props} child]
   (rum/use-effect!
    #(let [doc js/document.documentElement
           cls (.-classList doc)]
@@ -24,11 +24,16 @@
    [sidebar-open?])
 
   (rum/use-effect!
-    (fn []
-      (ui-handler/add-style-if-exists!)
-      (pdf/reset-current-pdf!)
-      (plugin-handler/hook-plugin-app :current-graph-changed {}))
-    [current-repo])
+   #(if lsp-enabled?
+      (plugin-handler/setup-install-listener! t))
+   [t])
+
+  (rum/use-effect!
+   (fn []
+     (ui-handler/add-style-if-exists!)
+     (pdf/reset-current-pdf!)
+     (plugin-handler/hook-plugin-app :current-graph-changed {}))
+   [current-repo])
 
   (rum/use-effect!
    #(let [db-restored? (false? db-restoring?)]

+ 32 - 0
src/main/frontend/dicts.cljs

@@ -313,6 +313,22 @@
 
         :help/shortcut-page-title "Keyboard shortcuts"
 
+        :plugin/installed "Installed"
+        :plugin/installing "Installing"
+        :plugin/install "Install"
+        :plugin/reload "Reload"
+        :plugin/update "Update"
+        :plugin/updating "Updating"
+        :plugin/uninstall "Uninstall"
+        :plugin/marketplace "Marketplace"
+        :plugin/open-settings "Open settings"
+        :plugin/open-package "Open package"
+        :plugin/load-unpacked "Load unpacked plugin"
+        :plugin/open-preferences "Open plugin preferences file"
+        :plugin/restart "Restart"
+        :plugin/marketplace-tips "If the plugin you installed from marketplace for the first time not work properly, you could try to restart the application."
+        :plugin/up-to-date "It's up to date"
+
         :pdf/copy-ref "Copy ref"
         :pdf/copy-text "Copy text"
         :pdf/linked-ref "Linked references"
@@ -991,6 +1007,22 @@
            :user/delete-your-account "删除你的帐号"
            :user/delete-account-notice "你在 logseq.com 发布的页面(假如有的话)也会被删除。"
 
+           :plugin/installed "已安装"
+           :plugin/installing "安装中"
+           :plugin/install "安装"
+           :plugin/reload "重载"
+           :plugin/update "更新"
+           :plugin/updating "更新中"
+           :plugin/uninstall "卸载"
+           :plugin/marketplace "插件市场"
+           :plugin/open-settings "打开配置项"
+           :plugin/open-package "打开包目录"
+           :plugin/load-unpacked "手动载入外部插件"
+           :plugin/open-preferences "打开预设配置文件"
+           :plugin/restart "重启应用"
+           :plugin/marketplace-tips "如果首次从市场安装或更新的插件,遇到非预期工作情况,可以尝试重启应用。"
+           :plugin/up-to-date "已经是最新了"
+
            :pdf/copy-ref "复制引用"
            :pdf/copy-text "复制文本"
            :pdf/linked-ref "转到注解"

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

@@ -4,6 +4,7 @@
             [clojure.set :as set]
             [datascript.core :as d]
             [frontend.components.diff :as diff]
+            [frontend.components.plugins :as plugin]
             [frontend.components.encryption :as encryption]
             [frontend.components.git :as git-component]
             [frontend.components.shell :as shell]
@@ -139,6 +140,9 @@
 (defmethod handle :modal/show-cards [_]
   (state/set-modal! srs/global-cards))
 
+(defmethod handle :modal/show-themes-modal [_]
+  (plugin/open-select-theme!))
+
 (rum/defc modal-output
   [content]
   content)

+ 249 - 81
src/main/frontend/handler/plugin.cljs

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

+ 2 - 2
src/main/frontend/mixins.cljs

@@ -104,9 +104,9 @@
 (defn on-key-down
   ([state keycode-map]
    (on-key-down state keycode-map {}))
-  ([state keycode-map {:keys [not-matched-handler all-handler]}]
+  ([state keycode-map {:keys [not-matched-handler all-handler target]}]
    (let [node (rum/dom-node state)]
-     (listen state js/window "keydown"
+     (listen state (or target js/window) "keydown"
              (fn [e]
                (let [key-code (.-keyCode e)]
                  (if-let [f (get keycode-map key-code)]

+ 9 - 0
src/main/frontend/modules/shortcut/config.cljs

@@ -8,6 +8,7 @@
             [frontend.handler.route :as route-handler]
             [frontend.handler.search :as search-handler]
             [frontend.handler.ui :as ui-handler]
+            [frontend.handler.plugin :as plugin-handler]
             [frontend.modules.shortcut.before :as m]
             [frontend.state :as state]
             [frontend.util :refer [mac?]]))
@@ -359,6 +360,14 @@
     {:desc    "Toggle wide mode"
      :binding "t w"
      :fn      ui-handler/toggle-wide-mode!}
+    :ui/select-theme-color
+    {:desc    "Select available theme colors"
+     :binding    "t i"
+     :fn      plugin-handler/show-themes-modal!}
+    :ui/goto-plugins
+    {:desc    "Go to plugins dashboard"
+     :binding    "t p"
+     :fn      plugin-handler/goto-plugins-dashboard!}
     :editor/toggle-open-blocks
     {:desc    "Toggle open blocks (collapse or expand all blocks)"
      :binding "t o"

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

@@ -3,7 +3,7 @@
             [frontend.state :as state]
             [frontend.ui :as ui]
             [frontend.components.sidebar :as sidebar]
-            [frontend.handler.plugin :as plugin-handler]
+            [frontend.handler.plugin :refer [lsp-enabled?] :as plugin-handler]
             [frontend.context.i18n :as i18n]))
 
 (rum/defc route-view

+ 1 - 1
src/main/frontend/routes.cljs

@@ -76,4 +76,4 @@
 
    ["/plugins"
     {:name :plugins
-     :view plugins/installed-page}]])
+     :view plugins/plugins-page}]])

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

@@ -138,6 +138,9 @@
       :plugin/simple-commands       {}
       :plugin/selected-theme        nil
       :plugin/selected-unpacked-pkg nil
+      :plugin/marketplace-pkgs      nil
+      :plugin/marketplace-stats     nil
+      :plugin/installing            nil
       :plugin/active-readme         nil
 
       ;; pdf

+ 2 - 1
src/main/frontend/util/pool.cljs

@@ -11,7 +11,8 @@
   ([]
    (create-parser-pool! 8))
   ([num]
-   (p/let [static-path (if (util/electron?)
+   (p/let [static-path (if (and (util/electron?)
+                                (= "file:" (.-protocol js/location)))
                          (ipc/ipc :getDirname)
                          "/static")
            path (str static-path "/js/parser-worker.js")

+ 20 - 2
src/main/logseq/api.cljs

@@ -133,7 +133,7 @@
           sub-dir? (string/starts-with? user-path path)
           _ (when-not sub-dir? (log/info :debug user-path) (throw "access file denied"))
           exist? (fs/file-exists? "" user-path)
-          _ (when-not exist?(log/info :debug user-path) (throw "file not existed"))
+          _ (when-not exist? (log/info :debug user-path) (throw "file not existed"))
           _ (fs/unlink! repo user-path {})]))
 
 (def ^:export write_user_tmp_file
@@ -210,6 +210,13 @@
             path (util/node-path.join path "settings" (str key ".json"))]
       (fs/write-file! repo "" path (js/JSON.stringify data nil 2) {:skip-compare? true}))))
 
+(def ^:export unlink_plugin_user_settings
+  (fn [key]
+    (p/let [repo ""
+            path (plugin-handler/get-ls-dotdir-root)
+            path (util/node-path.join path "settings" (str key ".json"))]
+      (fs/unlink! repo path nil))))
+
 (def ^:export register_plugin_slash_command
   (fn [pid ^js cmd-actions]
     (when-let [[cmd actions] (bean/->clj cmd-actions)]
@@ -411,7 +418,10 @@
       (when-not (contains? block :block/name)
         (when-let [uuid (:block/uuid block)]
           (let [{:keys [includeChildren]} (bean/->clj opts)
-                block (if (not includeChildren) block (first (outliner-tree/blocks->vec-tree [block] uuid)))]
+                repo (state/get-current-repo)
+                block (if (not includeChildren)
+                        block (first (outliner-tree/blocks->vec-tree
+                                       (db-model/get-block-and-children repo uuid) uuid)))]
             (bean/->js (normalize-keyword-for-json block))))))))
 
 (def ^:export get_previous_sibling_block
@@ -462,6 +472,14 @@
             blocks (normalize-keyword-for-json blocks)]
         (bean/->js blocks)))))
 
+;; plugins
+(def ^:export __install_plugin
+  (fn [^js manifest]
+    (when-let [{:keys [repo id] :as mft} (bean/->clj manifest)]
+      (if-not (and repo id)
+        (throw (js/Error. "[required] :repo :id"))
+        (plugin-handler/install-marketplace-plugin mft)))))
+
 ;; db
 (defn ^:export q
   [query-string]

+ 9 - 5
tailwind.config.js

@@ -11,9 +11,18 @@ module.exports = {
   darkMode: 'class',
   theme: {
     extend: {
+      animation: {
+        'spin-reverse': 'spin 2s linear infinite reverse',
+      },
       spacing: {
         '128': '32rem',
         '144': '36rem'
+      },
+      scale: {
+        '200': '2',
+        '250': '2.5',
+        '300': '3',
+        '400': '4',
       }
     },
     colors: {
@@ -40,11 +49,6 @@ module.exports = {
       yellow: colors.amber,
       orange: colors.orange,
       rose: colors.rose
-    },
-    extend: {
-      animation: {
-        'spin-reverse': 'spin 2s linear infinite reverse',
-      }
     }
   }
 }

Some files were not shown because too many files changed in this diff